From 8519a6e16e35499fbdc0baf031b38f0eb6902627 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 31 Aug 2026 13:47:00 -0400 Subject: [PATCH 01/10] fix(desktop): derive agent availability from relay presence Keep lifecycle controls separate from online/offline status and report shutdown as a request, not confirmed termination. Model explicit snapshot and authored live presence in browser fixtures while preserving start animation and avatar continuity. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/playwright.config.ts | 1 + desktop/src/features/agents/AGENTS.md | 4 + .../agents/lib/managedAgentControlActions.ts | 4 +- .../agents/lib/useAgentAvailability.test.mjs | 95 +++++++++ .../agents/lib/useAgentAvailability.ts | 27 +++ .../agents/ui/AgentRuntimeAvatarControl.tsx | 31 ++- .../agents/ui/UnifiedAgentsSection.tsx | 5 + .../features/profile/ui/UserProfilePanel.tsx | 10 +- .../profile/ui/UserProfilePanelSections.tsx | 14 +- desktop/src/testing/e2eBridge.ts | 14 +- desktop/tests/e2e/agent-availability.spec.ts | 191 ++++++++++++++++++ desktop/tests/e2e/agents.spec.ts | 28 +++ desktop/tests/e2e/profile.spec.ts | 60 ++++-- docs/agent-availability.md | 30 +++ 14 files changed, 471 insertions(+), 43 deletions(-) create mode 100644 desktop/src/features/agents/lib/useAgentAvailability.test.mjs create mode 100644 desktop/src/features/agents/lib/useAgentAvailability.ts create mode 100644 desktop/tests/e2e/agent-availability.spec.ts create mode 100644 docs/agent-availability.md diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 14cab5c63b8..9a6c7266ffe 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -177,6 +177,7 @@ export default defineConfig({ name: "integration", testMatch: [ "**/agents.spec.ts", + "**/agent-availability.spec.ts", "**/agent-snapshot-recipient.spec.ts", "**/onboarding.spec.ts", "**/stream.spec.ts", diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index a4dd4b346d2..f803ec51136 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -205,6 +205,10 @@ with a TypeScript lookup table or an id comparison in a component. select a representative or offer persona Start; a relay persona link cannot borrow a local sibling's management controls. See [the identity contract](../../../../docs/agent-profile-identity.md). + Availability dots read relay presence, never a saved deployment + receipt or runtime status. Failed/disconnected reads are unknown; lifecycle + actions retain their separate routing. 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` (`ui/EffortPickerField.tsx`), a self-contained section component mounted in diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 8a4a6898cce..8223d500401 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -31,6 +31,7 @@ export type ManagedAgentActionResult = { noticeMessage?: string; }; +/** Lifecycle action routing only; deployed is a retained receipt, not presence. */ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } @@ -133,7 +134,8 @@ export async function stopManagedAgentWithRules({ agent.pubkey, ]); return { - noticeMessage: "Shutdown command sent. Agent will stop shortly.", + noticeMessage: + "Shutdown requested. This does not confirm the agent has stopped.", }; } diff --git a/desktop/src/features/agents/lib/useAgentAvailability.test.mjs b/desktop/src/features/agents/lib/useAgentAvailability.test.mjs new file mode 100644 index 00000000000..6bf1b25959b --- /dev/null +++ b/desktop/src/features/agents/lib/useAgentAvailability.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { resolveAgentAvailability } from "./useAgentAvailability.ts"; +import { + getManagedAgentPrimaryActionLabel, + isManagedAgentActive, +} from "./managedAgentControlActions.ts"; +import { AgentRuntimeAvatarControl } from "../ui/AgentRuntimeAvatarControl.tsx"; + +const deployed = { + status: "deployed", + backend: { type: "provider", id: "fixture" }, + backendAgentId: "retained-receipt", +}; + +for (const presence of ["online", "away", "offline", undefined]) { + test(`retained deployment receipt does not supply availability (${presence})`, () => { + const availability = resolveAgentAvailability(presence, true, true); + assert.equal(availability, presence ?? "offline"); + // Controls retain their existing routing. Offline is not permission to + // spawn a second body, nor proof that a shutdown message succeeded. + assert.equal(isManagedAgentActive(deployed), true); + assert.equal(getManagedAgentPrimaryActionLabel(deployed), "Shutdown"); + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + isActive: true, + availability, + isStarting: false, + label: "Agent", + startTestId: "start", + onStart() {}, + }), + ); + assert.doesNotMatch(html, /is running/); + assert.match( + html, + new RegExp( + `Agent: ${availability[0].toUpperCase()}${availability.slice(1)}`, + ), + ); + assert.equal(html.includes("bg-emerald-500"), availability === "online"); + assert.doesNotMatch(html, /data-testid="start"/); + }); +} + +for (const [loaded, connected] of [ + [false, true], + [true, false], + [false, false], +]) { + test(`unavailable presence is unknown, not cached online (${loaded}, ${connected})`, () => { + const availability = resolveAgentAvailability("online", loaded, connected); + assert.equal(availability, undefined); + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + isActive: true, + availability, + isStarting: false, + label: "Agent", + startTestId: "start", + onStart() {}, + }), + ); + assert.match(html, /Availability unknown/); + assert.doesNotMatch(html, /bg-emerald-500|is running/); + }); +} + +for (const lifecycle of ["running", "stopped"]) { + test(`local ${lifecycle} controls remain independent of online presence`, () => { + const agent = { status: lifecycle, backend: { type: "local" } }; + const isActive = isManagedAgentActive(agent); + assert.equal( + getManagedAgentPrimaryActionLabel(agent), + isActive ? "Stop" : "Start agent", + ); + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + isActive, + availability: "online", + isStarting: false, + label: "Local Agent", + startTestId: "start", + onStart() {}, + }), + ); + assert.equal(html.includes('data-testid="start"'), !isActive); + assert.equal(html.includes('data-testid="active"'), isActive); + }); +} diff --git a/desktop/src/features/agents/lib/useAgentAvailability.ts b/desktop/src/features/agents/lib/useAgentAvailability.ts new file mode 100644 index 00000000000..07da53c9b0b --- /dev/null +++ b/desktop/src/features/agents/lib/useAgentAvailability.ts @@ -0,0 +1,27 @@ +import { usePresenceQuery } from "@/features/presence/hooks"; +import type { PresenceStatus } from "@/shared/api/types"; +import { useRelayConnection } from "@/shared/api/useRelayConnection"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** Availability is relay presence, never a retained deployment receipt or PID. */ +export function resolveAgentAvailability( + status: PresenceStatus | undefined, + presenceLoaded: boolean, + connected: boolean, +): PresenceStatus | undefined { + // Missing entries in a successful presence snapshot mean offline. Failed or + // disconnected reads cannot establish availability (including cached online). + return presenceLoaded && connected ? (status ?? "offline") : undefined; +} + +/** Share the existing presence query/subscription; no separate status cache. */ +export function useAgentAvailability(pubkey: string | null | undefined) { + const query = usePresenceQuery(pubkey ? [pubkey] : []); + const connection = useRelayConnection(); + const status = resolveAgentAvailability( + pubkey ? query.data?.[normalizePubkey(pubkey)] : undefined, + query.isSuccess, + connection === "connected", + ); + return { query, status }; +} diff --git a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx index 8b0426e1f41..b948a55ce4d 100644 --- a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx +++ b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx @@ -7,6 +7,11 @@ import { STATUS_DOT_MASK_CURVE, } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { + getPresenceDotClassName, + getPresenceLabel, +} from "@/features/presence/lib/presence"; +import type { PresenceStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Spinner } from "@/shared/ui/spinner"; import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar"; @@ -16,7 +21,9 @@ type AgentRuntimeAvatarControlProps = { avatarUrl?: string | null; errorLabel?: string | null; errorTestId?: string; + /** Lifecycle bookkeeping controls actions, not the availability dot. */ isActive: boolean; + availability?: PresenceStatus; isRestarting?: boolean; isStarting: boolean; label: string; @@ -128,6 +135,7 @@ const MASK_TRANSITION = { export function AgentRuntimeAvatarControl({ activeTestId, avatarUrl, + availability, errorLabel, errorTestId, isActive, @@ -151,32 +159,35 @@ export function AgentRuntimeAvatarControl({ : "Start Agent"; const actionText = isRestartAction ? "Restart" : "Start"; const isPending = isStarting || isRestarting; - const showRunningDot = isActive && !isRestartAction; + const availabilityLabel = availability + ? getPresenceLabel(availability) + : "Availability unknown"; + const showStatusDot = isActive && !isRestartAction; const hasError = !isActive && !isPending && Boolean(errorLabel); const errorActionLabel = `${label} has a runtime error. Open runtime details.`; const transition = shouldReduceMotion ? { duration: 0 } : MASK_TRANSITION; const actionBadge = isRestartAction ? RESTART_ACTION_BADGE : START_ACTION_BADGE; - const badge = showRunningDot + const badge = showStatusDot ? ACTIVE_BADGE : hasError ? ERROR_BADGE : actionBadge; const actionCutoutWidth = - showRunningDot || hasError ? undefined : actionBadge.cutoutWidth; + showStatusDot || hasError ? undefined : actionBadge.cutoutWidth; return ( - {showRunningDot ? ( + {showStatusDot ? ( ) : (