Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions desktop/src/features/agents/lib/managedAgentControlActions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import test from "node:test";
import {
startManagedAgentWithRules,
respawnManagedAgentWithRules,
isManagedAgentActive,
isManagedAgentLive,
} from "./managedAgentControlActions.ts";

function agent(overrides = {}) {
Expand Down Expand Up @@ -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);
});
39 changes: 37 additions & 2 deletions desktop/src/features/agents/lib/managedAgentControlActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
Channel,
ManagedAgent,
PresenceLookup,
PresenceStatus,
RelayAgent,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
Expand Down Expand Up @@ -35,9 +36,43 @@ export function isManagedAgentActive(agent: Pick<ManagedAgent, "status">) {
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<ManagedAgent, "status" | "backend">,
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)) {
Expand Down
6 changes: 5 additions & 1 deletion desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
25 changes: 18 additions & 7 deletions desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"}
</Badge>
Expand Down Expand Up @@ -292,6 +294,7 @@ export function MembersSidebarMemberCard({
onUntimeout={onUntimeout}
onViewActivity={onViewActivity}
pairAction={pairAction}
presenceStatus={presenceStatus}
/>
) : null}
</div>
Expand Down Expand Up @@ -320,6 +323,7 @@ function MemberActionsMenu({
onUntimeout,
onViewActivity,
pairAction,
presenceStatus,
}: {
canChangeRole: boolean;
canModerateMember: boolean;
Expand All @@ -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";
Expand Down Expand Up @@ -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,
)}
</DropdownMenuItem>
{onEditRespondTo ? (
<DropdownMenuItem
Expand Down Expand Up @@ -514,8 +522,11 @@ function getPairActionIcon(action: ManagedAgentPairAction) {
return <Play className="h-4 w-4" />;
}

function getManagedAgentActionIcon(agent: ManagedAgent) {
if (isManagedAgentActive(agent)) {
function getManagedAgentActionIcon(
agent: ManagedAgent,
presenceStatus?: PresenceStatus | null,
) {
if (isManagedAgentLive(agent, presenceStatus)) {
return <Square className="h-4 w-4" />;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import {
respawnManagedAgentWithRules,
isManagedAgentActive,
isManagedAgentLive,
startManagedAgentWithRules,
stopManagedAgentWithRules,
} from "@/features/agents/lib/managedAgentControlActions";
Expand All @@ -25,6 +26,7 @@ import type {
ChannelMember,
ManagedAgent,
ManagedAgentRuntimeStatus,
PresenceStatus,
} from "@/shared/api/types";

type UseMembersSidebarActionsOptions = {
Expand Down Expand Up @@ -144,6 +146,7 @@ export function useMembersSidebarActions({
async function handleLifecycleAction(
agent: ManagedAgent,
runtime?: ManagedAgentRuntimeStatus,
presenceStatus?: PresenceStatus | null,
) {
clearActionFeedback();
setActiveActionKey(`agent:${agent.pubkey}`);
Expand All @@ -170,7 +173,7 @@ export function useMembersSidebarActions({
return;
}

if (isManagedAgentActive(agent)) {
if (isManagedAgentLive(agent, presenceStatus)) {
await stopManagedAgentWithRules({
agent,
...EMPTY_AGENT_CONTEXT,
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/profile/ui/UserProfilePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ export function UserProfilePanel({
useAgentLifecycleActions({
channels: channelsQuery.data,
managedAgent,
presenceStatus,
relayAgents: relayAgentsQuery.data,
startManagedAgent: startAgentMutation.mutateAsync,
stopManagedAgent: stopAgentMutation.mutateAsync,
Expand Down
15 changes: 8 additions & 7 deletions desktop/src/features/profile/ui/UserProfilePanelSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
14 changes: 11 additions & 3 deletions desktop/src/features/profile/ui/useAgentLifecycleActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;
stopManagedAgent: (pubkey: string) => Promise<unknown>;
Expand All @@ -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 ?? [],
Expand Down Expand Up @@ -58,6 +65,7 @@ export function useAgentLifecycleActions({
}, [
channels,
managedAgent,
presenceStatus,
relayAgents,
startManagedAgent,
stopManagedAgent,
Expand Down