diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 832908bf7f..274760725f 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -210,7 +210,11 @@ with a TypeScript lookup table or an id comparison in a component. actions retain their separate routing. Current exact-key Online/Away presence suppresses Start for an inactive local record without granting Stop authority; list/profile/member startup guards must not interpret Offline as proof of safe - startup. See + startup. Deletion also consumes that same exact-key availability reader: + unknown requests shutdown when a channel exists, request failure retains the + record, and only established Offline keeps the intentional no-request path. + Unqueried persona siblings are unknown. No presence state grants deletion or + Stop authority; native local stop-before-remove remains independent. See [the availability contract](../../../../docs/agent-availability.md). 14. **Thinking effort has two surfaces: a local-only WRITE control and a read-only two-facts DISPLAY.** The write control is `EffortPickerField` diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 8223d50040..aaf10075e0 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -1,10 +1,6 @@ import { sendChannelMessage } from "@/shared/api/tauri"; -import type { - Channel, - ManagedAgent, - PresenceLookup, - RelayAgent, -} from "@/shared/api/types"; +import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; +import type { AgentAvailabilityReader } from "./useAgentAvailability"; import { normalizePubkey } from "@/shared/lib/pubkey"; type DeleteManagedAgentInput = { @@ -23,7 +19,7 @@ type ManagedAgentChannelContext = { }; type ManagedAgentActionContext = ManagedAgentChannelContext & { - presenceLookup?: PresenceLookup | null; + getAvailability: AgentAvailabilityReader; }; export type ManagedAgentActionResult = { @@ -148,7 +144,7 @@ export async function deleteManagedAgentWithRules({ channels, deleteManagedAgent, preferredChannelId, - presenceLookup, + getAvailability, relayAgents, skipRemoteDeleteConfirm = false, }: { @@ -157,7 +153,7 @@ export async function deleteManagedAgentWithRules({ skipRemoteDeleteConfirm?: boolean; } & ManagedAgentActionContext): Promise { if (agent.backend.type === "provider" && agent.backendAgentId) { - const presence = presenceLookup?.[normalizePubkey(agent.pubkey)]; + const availability = getAvailability(agent.pubkey); const channelId = resolveManagedAgentChannelId(agent, { channels, preferredChannelId, @@ -165,14 +161,19 @@ export async function deleteManagedAgentWithRules({ }); if (channelId) { - if (presence === "online" || presence === "away") { + // Only established Offline preserves the intentional no-request path. + // Unknown is not evidence that shutdown can safely be skipped. + if (availability !== "offline") { await sendChannelMessage(channelId, "!shutdown", undefined, undefined, [ agent.pubkey, ]); if (!skipRemoteDeleteConfirm) { const confirmed = window.confirm( - "Shutdown command sent, but the agent may still be running. " + + (availability === undefined + ? "This agent’s availability is unknown. " + : "") + + "Shutdown requested, but the agent may still be running. " + "Deleting now removes the local record — the remote deployment " + "will be orphaned if shutdown hasn't completed. Continue?", ); @@ -195,7 +196,7 @@ export async function deleteManagedAgentWithRules({ if (!skipRemoteDeleteConfirm) { const confirmed = window.confirm( "This agent is deployed but not in any channel. " + - "Deleting will orphan the remote deployment (it will keep running). Continue?", + "Deleting removes the local management record; the remote deployment may still be running. Continue?", ); if (!confirmed) { return { cancelled: true }; diff --git a/desktop/src/features/agents/lib/managedAgentDeletionAvailability.test.mjs b/desktop/src/features/agents/lib/managedAgentDeletionAvailability.test.mjs new file mode 100644 index 0000000000..894af01fd4 --- /dev/null +++ b/desktop/src/features/agents/lib/managedAgentDeletionAvailability.test.mjs @@ -0,0 +1,469 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +const PK = "a".repeat(64); +const SIBLING = "b".repeat(64); +const agent = { + pubkey: PK, + name: "Remote", + personaId: "persona", + status: "deployed", + backend: { type: "provider", id: "fixture", config: {} }, + backendAgentId: "receipt", +}; +const channel = { id: "channel", name: "agents", memberPubkeys: [PK] }; +const directory = [ + { pubkey: PK, channels: ["agents"], channelIds: ["channel"] }, +]; +let act, + render, + cleanup, + waitFor, + createElement, + QueryClient, + QueryClientProvider; +let useAgentAvailabilityLookup, + useManagedAgentActions, + useProfileAgentDeletion, + CommunitiesProvider; +let deleteManagedAgentWithRules, deleteManagedAgent, relayClient, originals; +let connection, listeners, handlers, commands, confirms, clients; + +before(async () => { + Object.assign(globalThis, { + window: dom.window, + localStorage: dom.window.localStorage, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + commands.push([command, args]); + if (handlers.has(command)) return handlers.get(command)(args); + throw new Error(`Unexpected IPC: ${command}`); + }, + transformCallback: () => 1, + }; + ({ act, render, cleanup, waitFor } = await import("@testing-library/react")); + ({ createElement } = await import("react")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "../../communities/useCommunities.tsx" + )); + ({ useAgentAvailabilityLookup } = await import("./useAgentAvailability.ts")); + ({ useManagedAgentActions } = await import( + "../ui/useManagedAgentActions.ts" + )); + ({ useProfileAgentDeletion } = await import( + "../../profile/ui/UserProfilePanelDeletion.ts" + )); + ({ deleteManagedAgentWithRules } = await import( + "./managedAgentControlActions.ts" + )); + ({ deleteManagedAgent } = await import("../../../shared/api/tauri.ts")); + ({ relayClient } = await import("../../../shared/api/relayClient.ts")); + originals = { + getConnectionState: relayClient.getConnectionState, + subscribeToConnectionState: relayClient.subscribeToConnectionState, + }; + relayClient.getConnectionState = () => connection; + relayClient.subscribeToConnectionState = (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; +}); + +afterEach(() => { + cleanup(); + for (const client of clients ?? []) { + client.cancelQueries(); + client.clear(); + } +}); +after(() => { + Object.assign(relayClient, originals); + dom.window.close(); +}); + +function setup() { + clients = []; + commands = []; + confirms = []; + connection = "connected"; + listeners = new Set(); + handlers = new Map([ + ["get_presence", () => ({ [PK]: "online" })], + ["delete_managed_agent", () => null], + ["remove_channel_member", () => null], + ["send_channel_message", () => ({ event_id: "event", created_at: 0 })], + ["list_managed_agents", () => []], + ["get_relay_agents", () => []], + ["list_available_acp_runtimes", () => []], + ["get_channels", () => []], + ["plugin:event|listen", () => 1], + ["plugin:event|unlisten", () => null], + ]); + dom.window.confirm = (copy) => { + confirms.push(copy); + return true; + }; +} + +function mount( + owner, + { agents = [agent], keys = [PK], seedChannels = true } = {}, +) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + mutations: { retry: false, gcTime: 0 }, + }, + }); + clients.push(client); + client.setQueryData(["managed-agents"], agents); + client.setQueryData(["relay-agents"], directory); + if (seedChannels) client.setQueryData(["channels"], [channel]); + client.setQueryData(["globalAgentConfig"], { env_vars: {} }); + let current; + function AgentsSurface() { + current = useManagedAgentActions(); + return null; + } + function ProfileSurface() { + const availability = useAgentAvailabilityLookup(keys); + const deletion = useProfileAgentDeletion({ + channels: [channel], + managedAgents: agents, + managedAgent: agents[0], + relayAgents: agents.map((row) => ({ + ...directory[0], + pubkey: row.pubkey, + })), + getAvailability: availability.getAvailability, + deleteManagedAgent: ({ pubkey, forceRemoteDelete }) => + deleteManagedAgent(pubkey, forceRemoteDelete), + }); + current = { ...availability, ...deletion }; + return null; + } + const Surface = owner === "agents" ? AgentsSurface : ProfileSurface; + render( + createElement( + QueryClientProvider, + { client }, + createElement(CommunitiesProvider, null, createElement(Surface)), + ), + ); + return { client, current: () => current }; +} + +function effects() { + return commands.filter(([name]) => + [ + "send_channel_message", + "delete_managed_agent", + "remove_channel_member", + ].includes(name), + ); +} + +for (const owner of ["agents", "profile"]) { + for (const scenario of [ + "online", + "away", + "offline", + "missing", + "pending", + "failed-online", + "failed-offline", + "disconnected-online", + "disconnected-offline", + ]) { + test(`${owner} deletion uses resolved ${scenario} at the production hook/IPC boundary`, async () => { + setup(); + const warm = scenario.endsWith("-offline") ? "offline" : "online"; + handlers.set("get_presence", () => { + if (scenario === "pending") return new Promise(() => {}); + if (scenario === "missing") return {}; + return { [PK]: scenario.includes("-") ? warm : scenario }; + }); + const surface = mount(owner); + const key = ["presence", PK]; + if (scenario !== "pending") { + await waitFor(() => + assert.equal(surface.client.getQueryState(key)?.status, "success"), + ); + } + if (scenario.startsWith("failed")) { + handlers.set("get_presence", () => + Promise.reject("relay unreachable: request timed out"), + ); + await act(() => + surface.client.invalidateQueries({ queryKey: key, exact: true }), + ); + assert.equal(surface.client.getQueryState(key).status, "error"); + assert.deepEqual(surface.client.getQueryData(key), { [PK]: warm }); + } + if (scenario.startsWith("disconnected")) { + await act(async () => { + connection = "disconnected"; + for (const listener of listeners) listener(connection); + }); + assert.deepEqual(surface.client.getQueryData(key), { [PK]: warm }); + } + const unknown = scenario.includes("-") || scenario === "pending"; + await waitFor(() => + assert.equal( + surface.current().getAvailability(PK), + unknown ? undefined : scenario === "missing" ? "offline" : scenario, + ), + ); + commands.length = 0; + await act(async () => { + if (owner === "agents") await surface.current().handleDelete(PK); + else await surface.current().deleteManagedAgentRecord(agent); + }); + const shouldShutdown = scenario !== "offline" && scenario !== "missing"; + assert.deepEqual( + effects().map(([name]) => name), + [ + ...(shouldShutdown ? ["send_channel_message"] : []), + "delete_managed_agent", + "remove_channel_member", + ], + ); + if (shouldShutdown) { + assert.equal(effects()[0][1].content, "!shutdown"); + assert.deepEqual(effects()[0][1].mentionPubkeys, [PK]); + } + assert.deepEqual( + effects().find(([name]) => name === "delete_managed_agent")[1], + { + pubkey: PK, + forceRemoteDelete: true, + }, + ); + if (owner === "agents") { + assert.equal(confirms.length, 1); + if (unknown) { + assert.match(confirms[0], /availability is unknown/); + assert.doesNotMatch(confirms[0], /offline/i); + } else if (!shouldShutdown) assert.match(confirms[0], /is offline/); + } else + assert.deepEqual(confirms, [], "profile already obtained confirmation"); + }); + } +} + +test("reader retained across an await sees errors/disconnect, not cached success; unqueried siblings stay unknown", async () => { + setup(); + const surface = mount("profile"); + await waitFor(() => + assert.equal(surface.current().getAvailability(PK), "online"), + ); + const retainedReader = surface.current().getAvailability; + assert.equal(retainedReader(SIBLING), undefined); + handlers.set("get_presence", () => Promise.reject("failed")); + await act(() => + surface.client.invalidateQueries({ queryKey: ["presence", PK] }), + ); + assert.equal(retainedReader(PK), undefined); + await act(async () => + surface.client.setQueryData(["presence", PK], { [PK]: "online" }), + ); + connection = "reconnecting"; // even before the next React connection render + assert.equal(retainedReader(PK), undefined); +}); + +for (const owner of ["agents", "profile"]) { + test(`${owner} unknown shutdown failure preserves record and channel membership`, async () => { + setup(); + handlers.set("get_presence", () => Promise.reject("failed")); + handlers.set("send_channel_message", () => + Promise.reject(new Error("shutdown refused")), + ); + const surface = mount(owner); + await waitFor(() => + assert.equal( + surface.client.getQueryState(["presence", PK])?.status, + "error", + ), + ); + await act(async () => { + if (owner === "agents") await surface.current().handleDelete(PK); + else + await assert.rejects( + surface.current().deleteManagedAgentRecord(agent), + /shutdown refused/, + ); + }); + assert.deepEqual( + effects().map(([name]) => name), + ["send_channel_message"], + ); + assert.deepEqual(confirms, []); + if (owner === "agents") + assert.equal(surface.current().actionErrorMessage, "shutdown refused"); + }); +} + +test("unknown waits for shutdown before confirmation/delete; cancellation retains record", async () => { + setup(); + let release; + handlers.set( + "send_channel_message", + () => + new Promise((resolve) => { + release = resolve; + }), + ); + dom.window.confirm = (copy) => { + confirms.push(copy); + return false; + }; + const operation = deleteManagedAgentWithRules({ + agent, + channels: [channel], + relayAgents: directory, + getAvailability: () => undefined, + deleteManagedAgent: ({ pubkey, forceRemoteDelete }) => + deleteManagedAgent(pubkey, forceRemoteDelete), + }); + await waitFor(() => assert.equal(typeof release, "function")); + assert.deepEqual(confirms, []); + assert.equal(effects().length, 1); + release({ event_id: "event" }); + assert.deepEqual(await operation, { cancelled: true }); + assert.match(confirms[0], /availability is unknown/); + assert.equal(effects().length, 1); +}); + +test("no channel warns without claiming process state; local deletion ignores presence", async () => { + setup(); + const remove = ({ pubkey, forceRemoteDelete }) => + deleteManagedAgent(pubkey, forceRemoteDelete); + await deleteManagedAgentWithRules({ + agent, + channels: [], + relayAgents: [], + getAvailability: () => undefined, + deleteManagedAgent: remove, + }); + assert.match(confirms[0], /may still be running/); + assert.doesNotMatch(confirms[0], /will keep running|offline/i); + assert.deepEqual( + effects().map(([name]) => name), + ["delete_managed_agent"], + ); + commands.length = 0; + confirms.length = 0; + await deleteManagedAgentWithRules({ + agent: { ...agent, backend: { type: "local" } }, + channels: [], + relayAgents: [], + getAvailability: () => { + throw new Error("must not consult presence"); + }, + deleteManagedAgent: remove, + }); + assert.deepEqual(effects(), [ + ["delete_managed_agent", { pubkey: PK, forceRemoteDelete: null }], + ]); + assert.deepEqual(confirms, []); +}); + +test("Agents deletion rechecks availability after channel discovery, not the click-time snapshot", async () => { + setup(); + let releaseChannels; + handlers.set( + "get_channels", + () => + new Promise((resolve) => { + releaseChannels = resolve; + }), + ); + handlers.set("get_presence", () => ({ [PK]: "offline" })); + const surface = mount("agents", { seedChannels: false }); + await waitFor(() => + assert.equal(surface.current().getAvailability(PK), "offline"), + ); + let operation; + await act(async () => { + operation = surface.current().handleDelete(PK); + }); + await waitFor(() => assert.equal(typeof releaseChannels, "function")); + handlers.set("get_presence", () => Promise.reject("failed")); + await act(() => + surface.client.invalidateQueries({ queryKey: ["presence", PK] }), + ); + assert.deepEqual(effects(), []); + await act(async () => { + releaseChannels({ hash: "empty", channels: [], last_messages: {} }); + await operation; + }); + assert.deepEqual( + effects().map(([name]) => name), + ["send_channel_message", "delete_managed_agent", "remove_channel_member"], + ); + assert.match(confirms[0], /availability is unknown/); +}); + +test("profile persona deletion cannot infer Offline for an unqueried sibling", async () => { + setup(); + handlers.set("get_presence", () => ({})); + const surface = mount("profile", { + agents: [agent, { ...agent, pubkey: SIBLING }], + keys: [PK], + }); + await waitFor(() => + assert.equal(surface.current().getAvailability(PK), "offline"), + ); + assert.equal(surface.current().getAvailability(SIBLING), undefined); + await act(() => + surface.current().deleteManagedAgentsForPersona({ id: "persona" }), + ); + const requests = effects().filter( + ([name]) => name === "send_channel_message", + ); + assert.equal(requests.length, 1); + assert.deepEqual(requests[0][1].mentionPubkeys, [SIBLING]); + assert.match(confirms[0], /is offline/); + assert.match(confirms[1], /availability is unknown/); +}); + +test("successful cached snapshot remains authoritative during refetch; only settled error revokes it", async () => { + setup(); + const surface = mount("profile"); + await waitFor(() => + assert.equal(surface.current().getAvailability(PK), "online"), + ); + let rejectRead; + handlers.set( + "get_presence", + () => + new Promise((_, reject) => { + rejectRead = reject; + }), + ); + let refresh; + await act(async () => { + refresh = surface.client.invalidateQueries({ queryKey: ["presence", PK] }); + }); + assert.equal(surface.current().getAvailability(PK), "online"); + await act(async () => { + rejectRead("failed"); + await refresh; + }); + assert.equal(surface.current().getAvailability(PK), undefined); +}); diff --git a/desktop/src/features/agents/lib/useAgentAvailability.ts b/desktop/src/features/agents/lib/useAgentAvailability.ts index 6cf238ed56..d5e8f9995c 100644 --- a/desktop/src/features/agents/lib/useAgentAvailability.ts +++ b/desktop/src/features/agents/lib/useAgentAvailability.ts @@ -1,6 +1,8 @@ import * as React from "react"; -import { usePresenceQuery } from "@/features/presence/hooks"; -import type { PresenceStatus } from "@/shared/api/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { presenceQueryKey, usePresenceQuery } from "@/features/presence/hooks"; +import { relayClient } from "@/shared/api/relayClient"; +import type { PresenceLookup, PresenceStatus } from "@/shared/api/types"; import { useRelayConnection } from "@/shared/api/useRelayConnection"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -39,18 +41,29 @@ export function useAgentAvailabilityLookup( options?: { enabled?: boolean }, ) { const query = usePresenceQuery(pubkeys, options); - const connection = useRelayConnection(); - const getAvailability: AgentAvailabilityReader = React.useCallback( - (pubkey) => - pubkey - ? resolveAgentAvailability( - query.data?.[normalizePubkey(pubkey)], - query.isSuccess, - connection === "connected", - ) - : undefined, - [query.data, query.isSuccess, connection], - ); + const queryClient = useQueryClient(); + // Subscribe for display updates; action-time reads below must also see a + // disconnect/error that occurred while an action awaited channel discovery. + const connection = useRelayConnection({ degradedAfterMs: 0 }); + const keyId = JSON.stringify(presenceQueryKey(pubkeys)); + // These dependencies subscribe the render to data/status changes, while the + // callback reads the cache at invocation time (including after an await). + // biome-ignore lint/correctness/useExhaustiveDependencies: Track observer changes and invalidate memoized consumers; invocation must read live state, not capture those values. + const getAvailability: AgentAvailabilityReader = React.useMemo(() => { + const key: string[] = JSON.parse(keyId); + const requested = new Set(key.slice(1)); + return (pubkey) => { + const normalized = pubkey ? normalizePubkey(pubkey) : ""; + // A successful subset says nothing about unqueried persona siblings. + if (!normalized || !requested.has(normalized)) return undefined; + const state = queryClient.getQueryState(key); + return resolveAgentAvailability( + state?.data?.[normalized], + state?.status === "success", + relayClient.getConnectionState() === "connected", + ); + }; + }, [keyId, queryClient, query.data, query.isSuccess, connection]); return { query, getAvailability }; } diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index c34cfbea4a..9c06044c5a 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -328,7 +328,7 @@ export function useManagedAgentActions() { agent, channels, deleteManagedAgent: deleteMutation.mutateAsync, - presenceLookup: managedPresenceQuery.data, + getAvailability, relayAgents: relayAgentsQuery.data ?? [], }); if (result.cancelled) return; diff --git a/desktop/src/features/presence/hooks.ts b/desktop/src/features/presence/hooks.ts index 63d5902b27..f8116f689d 100644 --- a/desktop/src/features/presence/hooks.ts +++ b/desktop/src/features/presence/hooks.ts @@ -45,7 +45,8 @@ function normalizePubkeys(pubkeys: string[]) { .sort(); } -function presenceQueryKey(pubkeys: string[]) { +/** Canonical normalized key shared by observers and action-time readers. */ +export function presenceQueryKey(pubkeys: string[]) { return ["presence", ...normalizePubkeys(pubkeys)] as const; } diff --git a/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx b/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx index b43bb3aa80..fd57dd35e2 100644 --- a/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx +++ b/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx @@ -264,7 +264,9 @@ function AgentDeleteConfirmDialog({ Delete this agent? - Deleting this agent stops and removes the agent from this community. + {isProviderAgent + ? "Deleting removes this agent’s local management record, not its remote deployment." + : "Deleting this agent stops and removes the agent from this community."} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index e6dc20e414..e2db5dcd4e 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -38,7 +38,7 @@ import { } from "@/features/agents/ui/personaDialogState"; import { useChannelsQuery } from "@/features/channels/hooks"; import { useIdentityArchive } from "@/features/identity-archive/hooks"; -import { useAgentAvailability } from "@/features/agents/lib/useAgentAvailability"; +import { useAgentAvailabilityLookup } from "@/features/agents/lib/useAgentAvailability"; import { useContactListQuery, useFollowMutation, @@ -251,8 +251,10 @@ export function UserProfilePanel({ effectivePubkey ? [effectivePubkey] : [], ); const channelsQuery = useChannelsQuery(); - const { query: presenceQuery, status: presenceStatus } = - useAgentAvailability(effectivePubkey); + const { query: presenceQuery, getAvailability } = useAgentAvailabilityLookup( + effectivePubkey ? [effectivePubkey] : [], + ); + const presenceStatus = getAvailability(effectivePubkey); const userStatusQuery = useUserStatusQuery( effectivePubkey ? [effectivePubkey] : [], ); @@ -411,7 +413,7 @@ export function UserProfilePanel({ deleteManagedAgent: deleteAgentMutation.mutateAsync, managedAgent, managedAgents: managedAgentsQuery.data, - presenceLookup: presenceQuery.data, + getAvailability, relayAgents: relayAgentsQuery.data, }); diff --git a/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts b/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts index c708bffe82..7e664e5c72 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts @@ -11,7 +11,6 @@ import type { AgentPersona, Channel, ManagedAgent, - PresenceLookup, RelayAgent, } from "@/shared/api/types"; import { getRelayAgentChannelIds } from "@/features/profile/ui/UserProfilePanelUtils"; @@ -36,7 +35,7 @@ type UseProfileAgentDeletionInput = { deleteManagedAgent: DeleteManagedAgentRulesContext["deleteManagedAgent"]; managedAgent?: ManagedAgent; managedAgents?: readonly ManagedAgent[]; - presenceLookup?: PresenceLookup | null; + getAvailability: DeleteManagedAgentRulesContext["getAvailability"]; relayAgents?: readonly RelayAgent[]; }; @@ -45,7 +44,7 @@ export function useProfileAgentDeletion({ deleteManagedAgent, managedAgent, managedAgents, - presenceLookup, + getAvailability, relayAgents, }: UseProfileAgentDeletionInput) { const queryClient = useQueryClient(); @@ -83,7 +82,7 @@ export function useProfileAgentDeletion({ deleteProfileManagedAgent(agentToDelete, { channels: channels ?? [], deleteManagedAgent, - presenceLookup, + getAvailability, relayAgents: relayAgents ?? [], removeAgentFromAllChannels, skipRemoteDeleteConfirm: true, @@ -91,7 +90,7 @@ export function useProfileAgentDeletion({ [ channels, deleteManagedAgent, - presenceLookup, + getAvailability, relayAgents, removeAgentFromAllChannels, ], @@ -103,7 +102,7 @@ export function useProfileAgentDeletion({ channels: channels ?? [], deleteManagedAgent, managedAgents: managedAgents ?? [], - presenceLookup, + getAvailability, relayAgents: relayAgents ?? [], removeAgentFromAllChannels, selectedAgent: managedAgent, @@ -113,7 +112,7 @@ export function useProfileAgentDeletion({ deleteManagedAgent, managedAgent, managedAgents, - presenceLookup, + getAvailability, relayAgents, removeAgentFromAllChannels, ], diff --git a/desktop/tests/e2e/agent-availability.spec.ts b/desktop/tests/e2e/agent-availability.spec.ts index 7ff5a953fb..ad59e96e2a 100644 --- a/desktop/tests/e2e/agent-availability.spec.ts +++ b/desktop/tests/e2e/agent-availability.spec.ts @@ -592,3 +592,153 @@ test("hover profile preserves unknown availability and announces only establishe await expectUnknown(); await expect(popover).toBeVisible(); }); + +for (const scenario of [ + "failed-online", + "disconnected-online", + "failed-offline", + "offline", +] as const) { + test(`profile deletion respects ${scenario} and preserves the record on shutdown failure`, async ({ + page, + }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: LOCAL, + name: "Deletion fixture", + status: "deployed", + backend: { type: "provider", id: "fixture", config: {} }, + channelNames: ["agents"], + }, + ], + }); + await page.goto("/#/agents"); + await expect( + page.getByRole("button", { name: "Deletion fixture agent profile" }), + ).toBeVisible(); + await page.evaluate( + async ({ pubkey, warm }) => { + const w = window as typeof window & { + __DELETE_FAIL_PRESENCE__?: boolean; + __DELETE_FAIL_SHUTDOWN__?: boolean; + __DELETE_EFFECTS__?: string[]; + __TAURI_INTERNALS__: { + invoke: ( + command: string, + payload: unknown, + options: unknown, + ) => Promise; + }; + }; + const original = w.__TAURI_INTERNALS__.invoke.bind( + w.__TAURI_INTERNALS__, + ); + w.__DELETE_EFFECTS__ = []; + w.__DELETE_FAIL_SHUTDOWN__ = true; + w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => { + if (command === "list_managed_agents") { + const rows = (await original(command, payload, options)) as Array<{ + pubkey: string; + backend_agent_id: string | null; + }>; + return rows.map((row) => + row.pubkey === pubkey + ? { ...row, backend_agent_id: "fixture-receipt" } + : row, + ); + } + if (command === "get_presence") { + if (w.__DELETE_FAIL_PRESENCE__) + throw "relay unreachable: request timed out"; + return { [pubkey]: warm }; + } + if ( + command === "send_channel_message" && + (payload as { content?: string })?.content === "!shutdown" + ) { + w.__DELETE_EFFECTS__?.push("shutdown"); + if (w.__DELETE_FAIL_SHUTDOWN__) throw "shutdown refused"; + } + if (command === "delete_managed_agent") + w.__DELETE_EFFECTS__?.push("delete"); + if (command === "remove_channel_member") + w.__DELETE_EFFECTS__?.push("remove-member"); + return original(command, payload, options); + }; + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["managed-agents"], + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["presence", pubkey], + exact: true, + }); + }, + { + pubkey: LOCAL, + warm: scenario.endsWith("offline") ? "offline" : "online", + }, + ); + await page + .getByRole("button", { name: "Deletion fixture agent profile" }) + .click(); + await expect( + page.getByTestId("user-profile-presence-badge"), + ).toHaveAttribute( + "aria-label", + scenario.endsWith("offline") ? "Offline" : "Online", + ); + if (scenario.startsWith("failed")) { + await page.evaluate(async (pubkey) => { + ( + window as typeof window & { __DELETE_FAIL_PRESENCE__?: boolean } + ).__DELETE_FAIL_PRESENCE__ = true; + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["presence", pubkey], + exact: true, + }); + }, LOCAL); + } else if (scenario.startsWith("disconnected")) { + await page.evaluate(() => + window.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?.("disconnected"), + ); + } + if (scenario !== "offline") + await expect(page.getByTestId("user-profile-presence-badge")).toHaveCount( + 0, + ); + await page.getByTestId("user-profile-delete-agent-row").click(); + const dialog = page.getByTestId("agent-delete-confirm-dialog"); + await expect(dialog).toContainText("not its remote deployment"); + await expect(dialog).toContainText("A failed request cancels deletion"); + await expect(dialog).not.toContainText("This agent is offline"); + await page.getByTestId("agent-delete-confirm-action").click(); + const effects = () => + page.evaluate( + () => + (window as typeof window & { __DELETE_EFFECTS__?: string[] }) + .__DELETE_EFFECTS__ ?? [], + ); + if (scenario === "offline") { + await expect.poll(effects).toEqual(["delete", "remove-member"]); + await expect(page.getByTestId("user-profile-panel")).toHaveCount(0); + } else { + await expect.poll(effects).toEqual(["shutdown"]); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + // Retry the same user action without healing presence. A successful + // request permits the explicitly confirmed local deletion, not a claim + // of remote termination. No membership write precedes it. + await page.evaluate(() => { + ( + window as typeof window & { __DELETE_FAIL_SHUTDOWN__?: boolean } + ).__DELETE_FAIL_SHUTDOWN__ = false; + }); + await page.getByTestId("user-profile-delete-agent-row").click(); + await page.getByTestId("agent-delete-confirm-action").click(); + await expect + .poll(effects) + .toEqual(["shutdown", "shutdown", "delete", "remove-member"]); + await expect(page.getByTestId("user-profile-panel")).toHaveCount(0); + } + }); +} diff --git a/docs/agent-availability.md b/docs/agent-availability.md index 1629039a23..826bc66746 100644 --- a/docs/agent-availability.md +++ b/docs/agent-availability.md @@ -3,7 +3,9 @@ Availability means conversational presence on the relay, not process health. A retained deployment receipt, PID, or `running` record cannot turn an agent's availability dot green. A successful presence snapshot with no entry means -Offline. A pending/failed read or disconnected relay means unknown, including +Offline **only for an identity included in that snapshot’s requested keys**. +An unqueried identity is unknown, never implicitly Offline. An initial pending +read, failed read, or disconnected relay means unknown, including when the cache previously contained Online. Online and Away require presence. Agents cards and profiles share the existing presence query and live subscription; @@ -34,6 +36,42 @@ subscription freshness remains authoritative; no separate heartbeat or cache is introduced. Runtime details remain accessible through the card/profile. +## Lifecycle decision authority + +Presence does not grant management authority. Backend type, deployment receipt, +local/pair ownership and the existing owner gates still decide which operations +are offered and where they route. All presence-dependent decisions use the same +surface-owned reader, not raw query data. The reader checks the canonical query +cache and current relay connection at invocation, including after asynchronous +channel discovery and between persona siblings. Display observers refresh on +query data/status and connection changes (without the warning-banner debounce). +A successful cached snapshot remains usable during an in-flight background +refetch under the existing query policy; a settled error revokes it even though +TanStack retains the old data. A live single-author update cannot heal a failed +aggregate. This is not a new freshness cache or a distributed lock. + +For deletion of a provider agent with a deployment receipt: + +| Availability / route | Before local record deletion | +| --- | --- | +| Online or Away, channel available | Await a shutdown request; warn it does not confirm termination. | +| Unknown, channel available | Also await shutdown; explicitly identify unknown availability in the action confirmation, never claim Offline. | +| Established Offline | Preserve intentional offline removal without a shutdown request; warn the remote deployment may still exist. | +| No channel | No shutdown route; warn the remote deployment may still run, without claiming it will. | + +A shutdown request failure propagates and leaves the management record and +channel memberships intact for retry; it is not silently converted into force +removal. A successful request still requires the existing orphan confirmation +(or the profile’s already-accepted equivalent) before forced **local record** +deletion. The profile confirmation describes this policy without asserting the +current availability or promising remote deletion. Cancelling after a request +retains the record; it cannot unsend the request. Channel removal follows record +deletion. Local deletion does not consult presence and still delegates to the +native stop-before-remove boundary. Custom-persona native cascade continues to +refuse provider-deployed targets; built-in persona instance removal uses these +same per-instance rules, treating unqueried siblings as unknown. + + ## Regression evidence - `desktop/src-tauri/src/commands/profile_presence_tests.rs`: actual command @@ -72,3 +110,13 @@ availability use `__BUZZ_E2E_EMIT_MOCK_PRESENCE__` to seed the snapshot and emit kind 20001 with the agent as author. Updating a directory row or posting a chat message is not presence. Mock browser tests do not certify native relay TTL, provider termination, or substrate health. + +- `managedAgentDeletionAvailability.test.mjs`: mounted production Agents and + profile deletion hooks through safe mock IPC, including failed/disconnected + cached Online **and Offline**, pending, genuine missing/Offline, Online/Away, + channel-discovery suspension, unqueried persona siblings, successful in-flight + refetch, shutdown failure/ordering/cancel, no-channel and local delegation. +- `agent-availability.spec.ts`: real profile Delete dialog/action with failed or + disconnected cached Online and failed cached Offline. A rejected shutdown + retains the record; retry orders shutdown → deletion → membership removal. + Genuine Offline preserves no-request deletion. All destructive IPC is mocked.