diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2e..8bab0c7a784 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -4,6 +4,8 @@ import test from "node:test"; import { startManagedAgentWithRules, respawnManagedAgentWithRules, + isManagedAgentActive, + isManagedAgentLive, } from "./managedAgentControlActions.ts"; function agent(overrides = {}) { @@ -166,3 +168,101 @@ test("test_respawn_onStopped_fires_before_start_resolves", async () => { "onStopped must fire after stop resolves and before start is called", ); }); + +// --- isManagedAgentActive: backend-agnostic, on purpose -------------------- +// +// runtime.rs's two-axis model: for a provider-backed agent, `status` is a +// control-plane fact ("the provider was invoked") that stays "deployed" +// forever once set — there is no v1 undeploy. It never reverts on its own +// after `!shutdown` takes the harness offline. respawnManagedAgentWithRules +// already knows how to redeploy a provider agent (it skips the stop step and +// just calls start), so a caller that additionally gates Restart on +// `backend.type === "local"` (as UserProfilePanelSections.tsx did) leaves +// every provider-backed agent with no way back from "Shutdown" once it goes +// offline — the primary action stays wired to `!shutdown` forever, never +// reaching `start` again. isManagedAgentActive itself must stay +// backend-agnostic so callers have no reason to bolt on that extra check. + +test("isManagedAgentActive is true for a deployed provider agent", () => { + const providerAgent = agent({ + status: "deployed", + backend: { type: "provider", id: "lxc", config: {} }, + }); + assert.equal(isManagedAgentActive(providerAgent), true); +}); + +test("isManagedAgentActive is true for a running local agent", () => { + const localAgent = agent({ status: "running" }); + assert.equal(isManagedAgentActive(localAgent), true); +}); + +test("isManagedAgentActive is false for a provider agent never deployed", () => { + const undeployedAgent = agent({ + status: "not_deployed", + backend: { type: "provider", id: "lxc", config: {} }, + }); + assert.equal(isManagedAgentActive(undeployedAgent), false); +}); + +test("isManagedAgentActive is false for a stopped local agent", () => { + const stoppedAgent = agent({ status: "stopped" }); + assert.equal(isManagedAgentActive(stoppedAgent), false); +}); + +// --- isManagedAgentLive: the live axis, distinct from control-plane status -- +// +// runtime.rs's two-axis model names this explicitly: "deployed" (control +// plane) tracks whether infrastructure exists; live presence (online/away/ +// offline) is "the real-time signal for whether the harness is connected". +// The UI's primary Shutdown/Deploy toggle and its status dot must use the +// LIVE axis for a provider agent — using control-plane status there (as +// isManagedAgentActive alone does) is exactly the bug that left the button +// and the dot stuck showing "online"/"Shutdown" forever after `!shutdown`, +// even though the agent was actually offline. + +test("isManagedAgentLive is true for a provider agent with online presence", () => { + const providerAgent = agent({ + status: "deployed", + backend: { type: "provider", id: "lxc", config: {} }, + }); + assert.equal(isManagedAgentLive(providerAgent, "online"), true); +}); + +test("isManagedAgentLive is true for a provider agent with away presence", () => { + const providerAgent = agent({ + status: "deployed", + backend: { type: "provider", id: "lxc", config: {} }, + }); + assert.equal(isManagedAgentLive(providerAgent, "away"), true); +}); + +test("isManagedAgentLive is false for a deployed provider agent that is offline", () => { + // The exact bug: status stays "deployed" forever, but presence says offline. + const providerAgent = agent({ + status: "deployed", + backend: { type: "provider", id: "lxc", config: {} }, + }); + assert.equal(isManagedAgentLive(providerAgent, "offline"), false); +}); + +test("isManagedAgentLive falls back to control-plane status when presence is unresolved", () => { + // Presence not loaded yet (undefined/null) must not flash "offline" — + // fall back to the prior, control-plane-only behavior. + const providerAgent = agent({ + status: "deployed", + backend: { type: "provider", id: "lxc", config: {} }, + }); + assert.equal(isManagedAgentLive(providerAgent, undefined), true); + assert.equal(isManagedAgentLive(providerAgent, null), true); +}); + +test("isManagedAgentLive ignores presence for local agents", () => { + // Local agents have no separate live axis: the local process IS the truth, + // already fully captured by status. A stray/unrelated presence value for a + // local agent must not override that. + const localAgent = agent({ status: "running" }); + assert.equal(isManagedAgentLive(localAgent, "offline"), true); + + const stoppedLocalAgent = agent({ status: "stopped" }); + assert.equal(isManagedAgentLive(stoppedLocalAgent, "online"), false); +}); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 8a4a6898cce..096e455832a 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -3,6 +3,7 @@ import type { Channel, ManagedAgent, PresenceLookup, + PresenceStatus, RelayAgent, } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -35,9 +36,43 @@ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } -export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { +/** + * Whether this agent is actually alive right now — the live axis of + * runtime.rs's two-axis status model, as distinct from `isManagedAgentActive` + * (the control-plane axis: "the provider was invoked"). + * + * For a provider-backed agent, `status` stays "deployed" forever once set — + * there is no v1 undeploy — so it never reflects a `!shutdown`. Real presence + * (online/away/offline) is the only live signal. When presence hasn't + * resolved yet (`undefined`/`null`, e.g. the presence query hasn't loaded), + * fall back to the control-plane status rather than flashing "offline". + * + * Local agents have no separate live axis: the local process IS the control + * plane, so `status` alone is authoritative and any unrelated presence value + * is ignored. + */ +export function isManagedAgentLive( + agent: Pick, + presenceStatus?: PresenceStatus | null, +): boolean { + if (agent.backend.type !== "provider") { + return isManagedAgentActive(agent); + } + if (presenceStatus === "online" || presenceStatus === "away") { + return true; + } + if (presenceStatus === "offline") { + return false; + } + return isManagedAgentActive(agent); +} + +export function getManagedAgentPrimaryActionLabel( + agent: ManagedAgent, + presenceStatus?: PresenceStatus | null, +) { if (agent.backend.type === "provider") { - return isManagedAgentActive(agent) ? "Shutdown" : "Deploy"; + return isManagedAgentLive(agent, presenceStatus) ? "Shutdown" : "Deploy"; } if (isManagedAgentActive(agent)) { diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 9e15cdc6359..76e7b8c9004 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -666,7 +666,11 @@ export function MembersSidebar({ }} onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined} onManagedAgentAction={(agent) => { - void handleAgentLifecycleAction(agent, managedAgentRuntime); + void handleAgentLifecycleAction( + agent, + managedAgentRuntime, + memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null, + ); }} onOpenProfile={handleOpenProfile} onRemoveMember={handleRemoveMember} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 76750490cde..8e5f38f78bd 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -16,7 +16,7 @@ import { import { getManagedAgentPrimaryActionLabel, - isManagedAgentActive, + isManagedAgentLive, } from "@/features/agents/lib/managedAgentControlActions"; import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; @@ -225,14 +225,16 @@ export function MembersSidebarMemberCard({ ? agentCommunityAvailability(managedAgentRuntime) === "Here" ? "default" : "secondary" - : managedAgent && isManagedAgentActive(managedAgent) + : managedAgent && + isManagedAgentLive(managedAgent, presenceStatus) ? "default" : "secondary" } > {managedAgentRuntime ? agentCommunityAvailability(managedAgentRuntime) - : managedAgent && isManagedAgentActive(managedAgent) + : managedAgent && + isManagedAgentLive(managedAgent, presenceStatus) ? "Running" : "Stopped"} @@ -292,6 +294,7 @@ export function MembersSidebarMemberCard({ onUntimeout={onUntimeout} onViewActivity={onViewActivity} pairAction={pairAction} + presenceStatus={presenceStatus} /> ) : null} @@ -320,6 +323,7 @@ function MemberActionsMenu({ onUntimeout, onViewActivity, pairAction, + presenceStatus, }: { canChangeRole: boolean; canModerateMember: boolean; @@ -340,6 +344,7 @@ function MemberActionsMenu({ onUntimeout: (member: ChannelMember) => void; onViewActivity?: (pubkey: string) => void; pairAction?: ManagedAgentPairAction; + presenceStatus?: PresenceStatus | null; }) { const showChangeRole = canChangeRole && !memberIsBot && member.role !== "owner"; @@ -380,10 +385,13 @@ function MemberActionsMenu({ > {pairAction ? getPairActionIcon(pairAction) - : getManagedAgentActionIcon(managedAgent)} + : getManagedAgentActionIcon(managedAgent, presenceStatus)} {pairAction ? MANAGED_AGENT_PAIR_ACTION_LABELS[pairAction] - : getManagedAgentPrimaryActionLabel(managedAgent)} + : getManagedAgentPrimaryActionLabel( + managedAgent, + presenceStatus, + )} {onEditRespondTo ? ( ; } -function getManagedAgentActionIcon(agent: ManagedAgent) { - if (isManagedAgentActive(agent)) { +function getManagedAgentActionIcon( + agent: ManagedAgent, + presenceStatus?: PresenceStatus | null, +) { + if (isManagedAgentLive(agent, presenceStatus)) { return ; } diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts index cc8f4062210..4fa75c30326 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -8,6 +8,7 @@ import { import { respawnManagedAgentWithRules, isManagedAgentActive, + isManagedAgentLive, startManagedAgentWithRules, stopManagedAgentWithRules, } from "@/features/agents/lib/managedAgentControlActions"; @@ -25,6 +26,7 @@ import type { ChannelMember, ManagedAgent, ManagedAgentRuntimeStatus, + PresenceStatus, } from "@/shared/api/types"; type UseMembersSidebarActionsOptions = { @@ -144,6 +146,7 @@ export function useMembersSidebarActions({ async function handleLifecycleAction( agent: ManagedAgent, runtime?: ManagedAgentRuntimeStatus, + presenceStatus?: PresenceStatus | null, ) { clearActionFeedback(); setActiveActionKey(`agent:${agent.pubkey}`); @@ -170,7 +173,7 @@ export function useMembersSidebarActions({ return; } - if (isManagedAgentActive(agent)) { + if (isManagedAgentLive(agent, presenceStatus)) { await stopManagedAgentWithRules({ agent, ...EMPTY_AGENT_CONTEXT, diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index f02b5aee097..0e9c1f726fd 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -455,6 +455,7 @@ export function UserProfilePanel({ useAgentLifecycleActions({ channels: channelsQuery.data, managedAgent, + presenceStatus, relayAgents: relayAgentsQuery.data, startManagedAgent: startAgentMutation.mutateAsync, stopManagedAgent: stopAgentMutation.mutateAsync, diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 0582e539652..ec4294465cd 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -5,6 +5,7 @@ import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { getManagedAgentPrimaryActionLabel, isManagedAgentActive, + isManagedAgentLive, } from "@/features/agents/lib/managedAgentControlActions"; import { RestartDiffBadge } from "@/features/agents/ui/RestartDiffBadge"; import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel"; @@ -191,7 +192,7 @@ export function ProfileSummaryView({ const activeTurns = useAgentWorking(isBot ? pubkey : null).channels; const avatarStatus = isBot ? managedAgent - ? isManagedAgentActive(managedAgent) + ? isManagedAgentLive(managedAgent, presenceStatus) ? "online" : "offline" : (presenceStatus ?? "offline") @@ -429,12 +430,13 @@ export function ProfileSummaryView({ agentActionDisabled={isAgentActionPending} agentActionLabel={ isOwner === true && managedAgent - ? getManagedAgentPrimaryActionLabel(managedAgent) + ? getManagedAgentPrimaryActionLabel(managedAgent, presenceStatus) : undefined } agentActionLive={ - managedAgent?.status === "running" || - managedAgent?.status === "deployed" + managedAgent + ? isManagedAgentLive(managedAgent, presenceStatus) + : false } onAgentPrimaryAction={ isOwner === true && managedAgent @@ -443,9 +445,8 @@ export function ProfileSummaryView({ } onAgentRestart={ isOwner === true && - managedAgent?.backend.type === "local" && - (managedAgent.status === "running" || - managedAgent.status === "deployed") + managedAgent && + isManagedAgentActive(managedAgent) ? handleAgentRestart : undefined } diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts index 62d0d3c7ad6..6d8a0759c1d 100644 --- a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -2,23 +2,30 @@ import * as React from "react"; import { toast } from "sonner"; import { - isManagedAgentActive, + isManagedAgentLive, respawnManagedAgentWithRules, startManagedAgentWithRules, stopManagedAgentWithRules, } from "@/features/agents/lib/managedAgentControlActions"; import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks"; -import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; +import type { + Channel, + ManagedAgent, + PresenceStatus, + RelayAgent, +} from "@/shared/api/types"; export function useAgentLifecycleActions({ channels, managedAgent, + presenceStatus, relayAgents, startManagedAgent, stopManagedAgent, }: { channels: readonly Channel[] | undefined; managedAgent: ManagedAgent | undefined; + presenceStatus?: PresenceStatus | null; relayAgents: readonly RelayAgent[] | undefined; startManagedAgent: (pubkey: string) => Promise; stopManagedAgent: (pubkey: string) => Promise; @@ -27,7 +34,7 @@ export function useAgentLifecycleActions({ if (!managedAgent) return; try { - if (isManagedAgentActive(managedAgent)) { + if (isManagedAgentLive(managedAgent, presenceStatus)) { const result = await stopManagedAgentWithRules({ agent: managedAgent, channels: channels ?? [], @@ -58,6 +65,7 @@ export function useAgentLifecycleActions({ }, [ channels, managedAgent, + presenceStatus, relayAgents, startManagedAgent, stopManagedAgent,