From 1ba1ffa337b907c9ab86fd5207e40a09e7109c37 Mon Sep 17 00:00:00 2001 From: arakakileo Date: Wed, 26 Aug 2026 21:18:09 -0400 Subject: [PATCH 1/3] fix(desktop): badge relay-admitted remote agents as channel members in mention autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent managed on another install that joins a channel appears in the relay's live agent directory with that channel in channelIds — the same relay-signed fact that admits it into autocomplete (relayAgentCanRespondInChannel) and authorizes mention delivery. But the relay-agent candidate loop in useMentions hardcoded isMember: false, and mentionSuggestionMapping derives the notInChannel badge from isMember === false, so while the member-roster cache was stale (up to CHANNEL_MEMBERS_STALE_TIME_MS = 5 min) an admitted, deliverable agent was badged "not in channel" in the very channel it had joined. Derive isMember for relay-agent candidates from the directory's channelIds in channel-scoped mention surfaces (stream/forum). Outside channel-scoped surfaces the badge keeps reflecting the roster only. Adds useMentionsRemoteMembership.test.mjs driving the real hook through a stale-roster repro: directory-confirmed member must not carry the badge, fresh-roster and managed-outside-channel cases pin the fix's scope. Signed-off-by: arakakileo --- .../src/features/messages/lib/useMentions.ts | 13 +- .../lib/useMentionsRemoteMembership.test.mjs | 266 ++++++++++++++++++ 2 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/messages/lib/useMentionsRemoteMembership.test.mjs diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 1a6ca1be4a7..6aa7fdd0c88 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -347,7 +347,17 @@ export function useMentions( kind: "identity", pubkey, displayName: agent.name, - isMember: false, + // The directory's `channelIds` is the relay-signed membership fact — + // the same source that admits the agent into autocomplete + // (relayAgentCanRespondInChannel) and authorizes mention delivery. + // The member roster can lag a join by up to + // CHANNEL_MEMBERS_STALE_TIME_MS, so deriving isMember from the roster + // alone badged admitted agents "not in channel" until the cache + // lapsed. Fall back to false outside channel-scoped mention + // surfaces, where the badge reflects the roster only. + isMember: mentionChannelId + ? agent.channelIds.includes(mentionChannelId) + : false, personaId: managedAgentPersonaIdsByPubkey.get(pubkey) ?? (activePersonaById.has(pubkey) ? pubkey : undefined), @@ -433,6 +443,7 @@ export function useMentions( managedAgentsQuery.data, memberPubkeys, members, + mentionChannelId, mentionableAgentPubkeys, personaNameByPubkey, profiles, diff --git a/desktop/src/features/messages/lib/useMentionsRemoteMembership.test.mjs b/desktop/src/features/messages/lib/useMentionsRemoteMembership.test.mjs new file mode 100644 index 00000000000..dabdae2efd6 --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionsRemoteMembership.test.mjs @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// ── Remote-agent membership badge regression ───────────────────────────────── +// +// Repro shape (Zenbook, 2026-08-26): an agent managed on another install joins +// a channel. The relay's live agent directory lists it with that channel in +// `channelIds` — the same relay-signed fact that admits the agent into +// autocomplete (relayAgentCanRespondInChannel) and authorizes mention +// delivery. But the client's member roster (get_channel_members) is served +// from a react-query cache with a 5-minute freshness window, so it can predate +// the join. The relay-agent candidate loop in useMentions hardcodes +// `isMember: false`, and mentionSuggestionMapping derives `notInChannel` from +// `isMember === false` — so an admitted, deliverable agent gets badged +// "not in channel" until the roster cache lapses. +// +// These tests drive the real hook with a stale roster (no trace of the remote +// agent) and a live directory (channelIds contains the channel) and assert +// the badge is not applied. The roster-present and managed-outside cases pin +// the fix's scope. + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + localStorage: dom.window.localStorage, +}); + +const VIEWER = "a".repeat(64); +const CHANNEL_ID = "channel-general"; +const REMOTE_AGENT = "b".repeat(64); +const REMOTE_OWNER = "d".repeat(64); +const MANAGED_AGENT = "e".repeat(64); + +/** @type {Map Promise>} */ +const ipc = new Map(); + +window.__TAURI_INTERNALS__ = { + invoke: (command, args) => { + const handler = ipc.get(command); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${command}`)); + }, + transformCallback: () => Math.floor(Math.random() * 1e9), +}; + +// Production imports after the DOM/IPC shims are in place. +const { act, cleanup, renderHook, waitFor } = await import( + "@testing-library/react" +); +const { default: React } = await import("react"); +const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" +); +const { CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" +); +const { useMentions } = await import("./useMentions.ts"); + +function memberRaw(pubkey, overrides = {}) { + return { + pubkey, + role: "admin", + is_agent: false, + joined_at: "2026-08-26T00:00:00Z", + display_name: "Leo", + ...overrides, + }; +} + +function remoteAgentRaw(overrides = {}) { + return { + pubkey: REMOTE_AGENT, + owner_pubkey: REMOTE_OWNER, + name: "Jarvis", + agent_type: "acp", + channels: [], + channel_ids: [CHANNEL_ID], + capabilities: [], + status: "online", + respond_to: "anyone", + respond_to_allowlist: [], + ...overrides, + }; +} + +function managedAgentRaw(overrides = {}) { + return { + pubkey: MANAGED_AGENT, + name: "LocalBot", + persona_id: null, + runtime: null, + team_id: null, + relay_url: "ws://reference.test", + acp_command: "acp", + agent_command: null, + agent_args: [], + mcp_command: null, + turn_timeout_seconds: 60, + idle_timeout_seconds: 60, + max_turn_duration_seconds: 120, + parallelism: 1, + system_prompt: "prompt", + avatar_url: null, + model: null, + status: "stopped", + pid: null, + created_at: "2026-08-26T00:00:00Z", + updated_at: "2026-08-26T00:00:00Z", + last_started_at: null, + last_stopped_at: null, + last_exit_code: null, + last_error: null, + log_path: null, + start_on_app_launch: false, + backend: null, + backend_agent_id: null, + respond_to: "owner-only", + respond_to_allowlist: [], + ...overrides, + }; +} + +function installDirectory({ relayAgents, members, managedAgents = [] }) { + ipc.clear(); + ipc.set("get_identity", async () => ({ + pubkey: VIEWER, + display_name: "Leo", + })); + ipc.set("get_channel_members", async () => ({ + members, + next_cursor: null, + })); + ipc.set("list_relay_agents", async () => relayAgents); + ipc.set("list_managed_agents", async () => managedAgents); + ipc.set("list_personas", async () => []); + ipc.set("list_teams", async () => []); + ipc.set("list_archived_identities", async () => ({ archived: [] })); + ipc.set("get_users_batch", async ({ pubkeys }) => ({ + profiles: {}, + missing: pubkeys ?? [], + })); + ipc.set("search_users", async () => ({ users: [], next_cursor: null })); + ipc.set("revalidate_relay_agents", async () => []); +} + +function mountMentions() { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Number.POSITIVE_INFINITY }, + }, + }); + return renderHook( + () => + useMentions(CHANNEL_ID, undefined, undefined, { channelType: "stream" }), + { + wrapper: ({ children }) => + React.createElement( + QueryClientProvider, + { client }, + React.createElement(CommunitiesProvider, null, children), + ), + }, + ); +} + +async function openPicker(view, query) { + await waitFor( + () => assert.equal(view.result.current.hasResolvedMembers, true), + { + timeout: 3000, + }, + ); + await act(async () => { + view.result.current.updateMentionQuery(`@${query}`, 1 + query.length); + }); + // Flush the 120ms mention debounce inside act so the query state settles. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + await waitFor( + () => + assert.ok( + view.result.current.suggestions.length > 0, + "suggestions appear", + ), + { timeout: 3000 }, + ); +} + +afterEach(async () => { + cleanup(); + dom.window.localStorage.clear(); +}); + +test("admitted remote agent is not badged not-in-channel when the roster cache predates its join", async () => { + // Stale roster: the cached member list has no trace of the remote agent, + // but the relay's live directory (`channel_ids`) says it is in the + // channel — the same fact that admits it into autocomplete and authorizes + // delivery. + installDirectory({ + relayAgents: [remoteAgentRaw()], + members: [memberRaw(VIEWER)], + }); + const view = mountMentions(); + await openPicker(view, "jar"); + const suggestion = view.result.current.suggestions.find( + (item) => item.displayName === "Jarvis", + ); + assert.ok(suggestion, "remote agent is admitted into autocomplete"); + assert.equal(suggestion.isAgent, true); + assert.equal( + suggestion.notInChannel, + false, + "the stale roster cache must not badge a directory-confirmed member as outside the channel", + ); +}); + +test("remote agent present in a fresh roster keeps no not-in-channel badge", async () => { + // Fresh roster: the merge path already marks the candidate as a member. + // Guards against regressing the roster-present case while fixing the + // stale-roster case above. + installDirectory({ + relayAgents: [remoteAgentRaw()], + members: [ + memberRaw(VIEWER), + memberRaw(REMOTE_AGENT, { + role: "bot", + is_agent: true, + display_name: "Jarvis", + }), + ], + }); + const view = mountMentions(); + await openPicker(view, "jar"); + const suggestion = view.result.current.suggestions.find( + (item) => item.displayName === "Jarvis", + ); + assert.ok(suggestion, "remote agent is admitted into autocomplete"); + assert.equal(suggestion.notInChannel, false); +}); + +test("local managed agent outside the roster keeps the not-in-channel badge", async () => { + // The fix must stay scoped: a locally managed agent that genuinely is not + // a member of the channel still carries the badge. + installDirectory({ + relayAgents: [], + members: [memberRaw(VIEWER)], + managedAgents: [managedAgentRaw()], + }); + const view = mountMentions(); + await openPicker(view, "loc"); + const suggestion = view.result.current.suggestions.find( + (item) => item.displayName === "LocalBot", + ); + assert.ok(suggestion, "managed agent is admitted into autocomplete"); + assert.equal(suggestion.isAgent, true); + assert.equal(suggestion.notInChannel, true); +}); From 0c398d40d1e5d6a41fdee007666ee53c2f7ada1f Mon Sep 17 00:00:00 2001 From: arakakileo Date: Wed, 26 Aug 2026 23:39:55 -0400 Subject: [PATCH 2/3] fix(desktop): stop test leaks holding the runner open after assertions GATE 1 rework for the remote-agent membership badge fix. The new useMentionsRemoteMembership suite passed in ~1s but its node --test process only exited after ~302s without --test-force-exit. Instrumented timer bookkeeping shows the survivors: twelve 300000ms GC timers from @tanstack/query-core, scheduled by Query.removeObserver -> scheduleGc when each test's last observer unmounts. cleanup() unmounts React but never touches the QueryClient, so the GC timers keep the event loop alive for the full 5 minutes. Track the per-test QueryClients and clear() them in afterEach: client.clear() removes all queries, and Removable.destroy() cancels the pending GC timeout. Focal runner now exits 0 in ~8s with no force-exit. The full-suite run then exposed a pre-existing flake in useDocumentVisible.test.mjs ("focused polling pauses on blur and resumes after activation yields"): the resume notification is delivered through scheduleAfterForegroundReady's chained 0ms timers, and under full-suite load those timers exceed the test's fixed 10ms real-time wait. Proven base flake: ceb8ba605 reproduces the identical failure signature (1/4 runs, same AssertionError + post-teardown "window is not defined" from the late trailing task). Make the wait deterministic by polling for the resume with a 5s deadline instead of a constant sleep, and bail out if the window global was already restored so a late resume cannot throw into an unrelated test's output. Full desktop suite without --test-force-exit: 3x consecutive runs exit 0 (5674/5674 each). biome, tsc --noEmit, and git diff --check green. Signed-off-by: arakakileo --- .../lib/useMentionsRemoteMembership.test.mjs | 10 ++++++++++ .../src/shared/lib/useDocumentVisible.test.mjs | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/messages/lib/useMentionsRemoteMembership.test.mjs b/desktop/src/features/messages/lib/useMentionsRemoteMembership.test.mjs index dabdae2efd6..fcda0a82a4a 100644 --- a/desktop/src/features/messages/lib/useMentionsRemoteMembership.test.mjs +++ b/desktop/src/features/messages/lib/useMentionsRemoteMembership.test.mjs @@ -157,6 +157,7 @@ function mountMentions() { queries: { retry: false, gcTime: Number.POSITIVE_INFINITY }, }, }); + queryClients.push(client); return renderHook( () => useMentions(CHANNEL_ID, undefined, undefined, { channelType: "stream" }), @@ -195,8 +196,17 @@ async function openPicker(view, query) { ); } +// QueryClients created by mountMentions. React-query schedules a 5-minute GC +// timer per query when its last observer unmounts; without clear() those +// timers keep the event loop (and the test runner) alive for ~300s after the +// last assertion. Each test clears its own clients before the next mount. +const queryClients = []; + afterEach(async () => { cleanup(); + for (const client of queryClients.splice(0)) { + await client.clear(); + } dom.window.localStorage.clear(); }); diff --git a/desktop/src/shared/lib/useDocumentVisible.test.mjs b/desktop/src/shared/lib/useDocumentVisible.test.mjs index bd3d54cbb37..50d31e52dae 100644 --- a/desktop/src/shared/lib/useDocumentVisible.test.mjs +++ b/desktop/src/shared/lib/useDocumentVisible.test.mjs @@ -271,6 +271,7 @@ describe("visibility-gated hooks", () => { window: dom.window, }); const observed = []; + const domWindow = globalThis.window; function Harness() { const refetchInterval = useFocusedRefetchInterval(1_000); React.useEffect(() => { @@ -287,9 +288,18 @@ describe("visibility-gated hooks", () => { focused = true; await act(async () => window.dispatchEvent(new window.Event("focus"))); assert.deepEqual(observed, [1_000, false]); - await act( - async () => new Promise((resolve) => window.setTimeout(resolve, 10)), - ); + // The resume notification runs after the activation turn yields + // (task -> frame -> task in scheduleAfterForegroundReady). Under load the + // chained 0ms timers can exceed any fixed real-time wait, so poll with a + // deadline instead of sleeping a constant 10ms. + const resumeDeadline = Date.now() + 5_000; + while ( + observed.length < 3 && + Date.now() < resumeDeadline && + globalThis.window === domWindow + ) { + await act(async () => new Promise((resolve) => setTimeout(resolve, 10))); + } assert.deepEqual(observed, [1_000, false, 1_000]); await act(async () => root.unmount()); From bab1b08f31ebbb05ae558b5bfb62ad132c377ca4 Mon Sep 17 00:00:00 2001 From: arakakileo Date: Thu, 27 Aug 2026 00:44:52 -0400 Subject: [PATCH 3/3] fix(desktop): cancel in-flight queries before clear and mock media poll IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GATE 1 rework round 2 for the remote-agent membership badge fix. The Friday gate proved timers still active after client.clear() at fe78a2b19: in-flight queries re-arm stale/GC timers. Root cause is an ordering race in @tanstack/query-core 5.100.14: Query.fetch() ends with `finally { this.scheduleGc() }`, so a fetch that settles AFTER clear() re-arms a 300s GC timer on a query that clear() already removed from the cache — nobody ever calls destroy() on it again, and the timer holds the event loop open. Fix, in afterEach, in order: - await client.cancelQueries() first: settles every in-flight fetch (the CancelledError path also runs the finally) while the queries are still cache-resident, so scheduleGc() lands before removal; - await client.clear() after: remove() -> destroy() -> clearGcTimeout() now finds and cancels every GC timer. The remaining ~5s post-assertion tail was a separate leak: mediaUrl.ts starts a shared proxy-port poll at module load (it sees the JSDOM window), and the fixture only installed IPC handlers after the production imports, so the poll spun its full 5s deadline on rejected invokes. Install static success handlers for get_relay_http_url and get_media_proxy_port before any production import so the poll resolves on its first iteration. Also close the JSDOM window in after() so the dom itself cannot keep the loop referenced. Evidence at this SHA: - focal 3/3 exit 0, wall ~4.3s each (was ~8s), no force-exit; timer-leak probe shows zero surviving timers >=100ms; - full desktop suite 3x consecutive: 5674/5674 exit 0, wall 120-125s, no force-exit; - biome check and tsc --noEmit clean. Signed-off-by: arakakileo --- .../lib/useMentionsRemoteMembership.test.mjs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/messages/lib/useMentionsRemoteMembership.test.mjs b/desktop/src/features/messages/lib/useMentionsRemoteMembership.test.mjs index fcda0a82a4a..9af4a920acd 100644 --- a/desktop/src/features/messages/lib/useMentionsRemoteMembership.test.mjs +++ b/desktop/src/features/messages/lib/useMentionsRemoteMembership.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { afterEach, test } from "node:test"; +import { after, afterEach, test } from "node:test"; import { JSDOM } from "jsdom"; @@ -51,6 +51,14 @@ window.__TAURI_INTERNALS__ = { transformCallback: () => Math.floor(Math.random() * 1e9), }; +// The media URL module starts a shared proxy-port poll at import time (it +// sees the JSDOM window). Without handlers for its two commands the poll +// spins for its full 5s deadline on rejected invokes, and that timer chain +// outlives the tests. Install static success handlers BEFORE any production +// import so the poll resolves on its first iteration instead. +ipc.set("get_relay_http_url", async () => "http://localhost"); +ipc.set("get_media_proxy_port", async () => 3128); + // Production imports after the DOM/IPC shims are in place. const { act, cleanup, renderHook, waitFor } = await import( "@testing-library/react" @@ -199,17 +207,29 @@ async function openPicker(view, query) { // QueryClients created by mountMentions. React-query schedules a 5-minute GC // timer per query when its last observer unmounts; without clear() those // timers keep the event loop (and the test runner) alive for ~300s after the -// last assertion. Each test clears its own clients before the next mount. +// last assertion. Each test settles and clears its own clients. +// +// Order matters: Query.fetch() ends with `finally { this.scheduleGc() }`, so +// a fetch that settles AFTER clear() re-arms a 300s GC timer on a query that +// clear() already removed from the cache — nobody calls destroy() on it again. +// Awaiting cancelQueries() first settles every in-flight fetch (CancelledError +// path runs the finally) while the queries are still cache-resident, and the +// subsequent clear() removes+destroys them with no surviving timers. const queryClients = []; afterEach(async () => { cleanup(); for (const client of queryClients.splice(0)) { + await client.cancelQueries(); await client.clear(); } dom.window.localStorage.clear(); }); +after(() => { + dom.window.close(); +}); + test("admitted remote agent is not badged not-in-channel when the roster cache predates its join", async () => { // Stale roster: the cached member list has no trace of the remote agent, // but the relay's live directory (`channel_ids`) says it is in the