mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
fix(desktop): guard stopped agent startup with authored presence
Show exact-key Online/Away independently of local lifecycle. Replace card Start with presence and guard profile/member/callback startup without inventing Stop authority. Cover rendered controls, keyboard activation and presence transitions. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -207,7 +207,10 @@ with a TypeScript lookup table or an id comparison in a component.
|
||||
[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
|
||||
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
|
||||
[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`
|
||||
|
||||
@@ -89,7 +89,31 @@ for (const lifecycle of ["running", "stopped"]) {
|
||||
onStart() {},
|
||||
}),
|
||||
);
|
||||
assert.equal(html.includes('data-testid="start"'), !isActive);
|
||||
assert.equal(html.includes('data-testid="active"'), isActive);
|
||||
assert.equal(html.includes('data-testid="start"'), false);
|
||||
assert.equal(html.includes('data-testid="active"'), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const availability of ["online", "away"]) {
|
||||
test(`stale restart and runtime error cannot hide stopped ${availability} presence`, () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(AgentRuntimeAvatarControl, {
|
||||
activeTestId: "active",
|
||||
startTestId: "start",
|
||||
errorTestId: "error",
|
||||
isActive: false,
|
||||
isStarting: false,
|
||||
requiresRestart: true,
|
||||
errorLabel: "Previous startup failed",
|
||||
availability,
|
||||
label: "Agent",
|
||||
onStart() {},
|
||||
}),
|
||||
);
|
||||
assert.match(html, /data-testid="active"/);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/data-testid="start"|data-testid="error"|<button/,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,19 @@ export function resolveAgentAvailability(
|
||||
return presenceLoaded && connected ? (status ?? "offline") : undefined;
|
||||
}
|
||||
|
||||
/** Positive presence blocks another start, but never grants lifecycle control.
|
||||
* Missing/offline presence is not proof that starting another body is safe.
|
||||
*/
|
||||
export function agentPresenceStartBlockReason(
|
||||
isLifecycleActive: boolean,
|
||||
availability: PresenceStatus | undefined,
|
||||
): string | undefined {
|
||||
return !isLifecycleActive &&
|
||||
(availability === "online" || availability === "away")
|
||||
? "This agent is present on the relay. Starting another instance is unavailable."
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Share the existing presence query/subscription; no separate status cache. */
|
||||
export function useAgentAvailability(pubkey: string | null | undefined) {
|
||||
const query = usePresenceQuery(pubkey ? [pubkey] : []);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import type { PresenceStatus } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { agentPresenceStartBlockReason } from "../lib/useAgentAvailability";
|
||||
import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar";
|
||||
|
||||
type AgentRuntimeAvatarControlProps = {
|
||||
@@ -162,7 +163,14 @@ export function AgentRuntimeAvatarControl({
|
||||
const availabilityLabel = availability
|
||||
? getPresenceLabel(availability)
|
||||
: "Availability unknown";
|
||||
const showStatusDot = isActive && !isRestartAction;
|
||||
const startBlockReason = agentPresenceStartBlockReason(
|
||||
isActive,
|
||||
availability,
|
||||
);
|
||||
// A present identity need not be a process this supervisor owns. Replace
|
||||
// Start (even a stale Restart/error badge) without inventing Stop authority.
|
||||
const showStatusDot =
|
||||
Boolean(startBlockReason) || (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;
|
||||
@@ -187,7 +195,7 @@ export function AgentRuntimeAvatarControl({
|
||||
className="h-full w-full rounded-full"
|
||||
data-testid={activeTestId}
|
||||
role="img"
|
||||
title={`${label}: ${availabilityLabel}`}
|
||||
title={startBlockReason ?? `${label}: ${availabilityLabel}`}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -293,3 +293,89 @@ test("errored avatar affordance still opens the explicit pubkey on the runtime t
|
||||
|
||||
assert.deepEqual(opened, [{ pubkey: LIVE_PK, options: { tab: "runtime" } }]);
|
||||
});
|
||||
|
||||
for (const kind of ["persona", "custom", "unknown"]) {
|
||||
test(`${kind} stopped card uses exact-key presence without inventing lifecycle controls`, async () => {
|
||||
installFailOpenIpc();
|
||||
const { relayClient } = await import("../../../shared/api/relayClient.ts");
|
||||
const originalConnection = relayClient.getConnectionState;
|
||||
const originalSubscribe = relayClient.subscribeToConnectionState;
|
||||
relayClient.getConnectionState = () => "connected";
|
||||
relayClient.subscribeToConnectionState = () => () => {};
|
||||
let snapshot = { [ARCHIVED_PK]: "online" };
|
||||
ipcHandlers.set("get_presence", () => Promise.resolve(snapshot));
|
||||
const starts = [];
|
||||
const props = baseProps({
|
||||
agents: [
|
||||
agent({
|
||||
personaId:
|
||||
kind === "custom"
|
||||
? null
|
||||
: kind === "unknown"
|
||||
? "missing"
|
||||
: "persona-1",
|
||||
}),
|
||||
],
|
||||
personas: kind === "persona" ? [persona()] : [],
|
||||
onStartAgent: (key) => starts.push(key),
|
||||
onRestartAgent: () => {
|
||||
throw new Error("presence must not cause Restart");
|
||||
},
|
||||
});
|
||||
try {
|
||||
await act(async () => renderSection(props));
|
||||
const client = clients.at(-1);
|
||||
const refresh = async () => {
|
||||
await act(async () => {
|
||||
await client.invalidateQueries({ queryKey: ["presence"] });
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
};
|
||||
await refresh();
|
||||
// Different-key Online must not suppress this identity's ordinary Start.
|
||||
fireEvent.click(screen.getByTestId(`agent-runtime-start-${LIVE_PK}`));
|
||||
assert.deepEqual(starts, [LIVE_PK]);
|
||||
starts.length = 0;
|
||||
for (const status of ["online", "away", "offline", "online"]) {
|
||||
snapshot = { [LIVE_PK]: status };
|
||||
await refresh();
|
||||
const start = screen.queryByTestId(`agent-runtime-start-${LIVE_PK}`);
|
||||
if (status === "offline") {
|
||||
assert.ok(start);
|
||||
fireEvent.click(start);
|
||||
assert.deepEqual(starts, [LIVE_PK]);
|
||||
starts.length = 0;
|
||||
} else {
|
||||
assert.equal(
|
||||
Boolean(start),
|
||||
false,
|
||||
"active exact-key presence must remove Start",
|
||||
);
|
||||
const dot = screen.getByTestId(`agent-runtime-active-${LIVE_PK}`);
|
||||
assert.match(
|
||||
dot.getAttribute("aria-label"),
|
||||
new RegExp(status === "online" ? "Online$" : "Away$"),
|
||||
);
|
||||
fireEvent.click(dot);
|
||||
fireEvent.keyDown(dot, { key: "Enter" });
|
||||
fireEvent.keyDown(dot, { key: " " });
|
||||
assert.deepEqual(starts, []);
|
||||
assert.equal(
|
||||
Boolean(screen.queryByRole("button", { name: /Stop/ })),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
// A successful omitted entry is the existing relay expiry/missing path.
|
||||
snapshot = {};
|
||||
await refresh();
|
||||
fireEvent.click(screen.getByTestId(`agent-runtime-start-${LIVE_PK}`));
|
||||
assert.deepEqual(starts, [LIVE_PK]);
|
||||
} finally {
|
||||
relayClient.getConnectionState = originalConnection;
|
||||
relayClient.subscribeToConnectionState = originalSubscribe;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
useStopManagedAgentMutation,
|
||||
useDeleteManagedAgentMutation,
|
||||
} from "@/features/agents/hooks";
|
||||
import {
|
||||
agentPresenceStartBlockReason,
|
||||
resolveAgentAvailability,
|
||||
} from "../lib/useAgentAvailability";
|
||||
import { useRelayConnection } from "@/shared/api/useRelayConnection";
|
||||
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness";
|
||||
@@ -102,6 +107,19 @@ export function useManagedAgentActions() {
|
||||
);
|
||||
|
||||
const managedPresenceQuery = usePresenceQuery(managedPubkeyList);
|
||||
const relayConnection = useRelayConnection();
|
||||
|
||||
function assertStartNotBlockedByPresence(agent: ManagedAgent) {
|
||||
const reason = agentPresenceStartBlockReason(
|
||||
isManagedAgentActive(agent),
|
||||
resolveAgentAvailability(
|
||||
managedPresenceQuery.data?.[normalizePubkey(agent.pubkey)],
|
||||
managedPresenceQuery.isSuccess,
|
||||
relayConnection === "connected",
|
||||
),
|
||||
);
|
||||
if (reason) throw new Error(reason);
|
||||
}
|
||||
|
||||
const channelsByPubkey = React.useMemo(() => {
|
||||
const map: Record<string, { id: string; name: string }[]> = {};
|
||||
@@ -164,6 +182,7 @@ export function useManagedAgentActions() {
|
||||
try {
|
||||
const agent = managedAgents.find((c) => c.pubkey === pubkey);
|
||||
if (!agent) return;
|
||||
assertStartNotBlockedByPresence(agent);
|
||||
await startManagedAgentWithRules({
|
||||
agent,
|
||||
startManagedAgent: startMutation.mutateAsync,
|
||||
@@ -184,6 +203,7 @@ export function useManagedAgentActions() {
|
||||
(candidate) => candidate.pubkey === pubkey,
|
||||
);
|
||||
if (!agent) return;
|
||||
assertStartNotBlockedByPresence(agent);
|
||||
await respawnManagedAgentWithRules({
|
||||
agent,
|
||||
startManagedAgent: startMutation.mutateAsync,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
agentPresenceStartBlockReason,
|
||||
useAgentAvailability,
|
||||
} from "@/features/agents/lib/useAgentAvailability";
|
||||
import {
|
||||
Activity,
|
||||
Ban,
|
||||
@@ -347,6 +351,14 @@ function MemberActionsMenu({
|
||||
const isBanned = moderationState?.banned ?? false;
|
||||
const isTimedOut = moderationState?.timedOut ?? false;
|
||||
|
||||
const { status: availability } = useAgentAvailability(managedAgent?.pubkey);
|
||||
const startBlockReason = managedAgent
|
||||
? agentPresenceStartBlockReason(
|
||||
pairAction ? pairAction === "stop" : isManagedAgentActive(managedAgent),
|
||||
availability,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -376,8 +388,11 @@ function MemberActionsMenu({
|
||||
{canViewActivity ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
data-testid={`sidebar-agent-action-${member.pubkey}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onManagedAgentAction(managedAgent)}
|
||||
disabled={disabled || Boolean(startBlockReason)}
|
||||
title={startBlockReason}
|
||||
onClick={() => {
|
||||
if (!startBlockReason) onManagedAgentAction(managedAgent);
|
||||
}}
|
||||
>
|
||||
{pairAction
|
||||
? getPairActionIcon(pairAction)
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
agentPresenceStartBlockReason,
|
||||
resolveAgentAvailability,
|
||||
} from "@/features/agents/lib/useAgentAvailability";
|
||||
import { usePresenceQuery } from "@/features/presence/hooks";
|
||||
import { useRelayConnection } from "@/shared/api/useRelayConnection";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -56,6 +63,24 @@ export function useMembersSidebarActions({
|
||||
relayUrl,
|
||||
}: UseMembersSidebarActionsOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const presenceQuery = usePresenceQuery(
|
||||
controllableManagedBots.map((agent) => agent.pubkey),
|
||||
);
|
||||
const connection = useRelayConnection();
|
||||
function assertStartNotBlockedByPresence(
|
||||
agent: ManagedAgent,
|
||||
lifecycleActive: boolean,
|
||||
) {
|
||||
const reason = agentPresenceStartBlockReason(
|
||||
lifecycleActive,
|
||||
resolveAgentAvailability(
|
||||
presenceQuery.data?.[normalizePubkey(agent.pubkey)],
|
||||
presenceQuery.isSuccess,
|
||||
connection === "connected",
|
||||
),
|
||||
);
|
||||
if (reason) throw new Error(reason);
|
||||
}
|
||||
const removeMemberMutation = useRemoveChannelMemberMutation(channelId);
|
||||
const startManagedAgentMutation = useStartManagedAgentMutation();
|
||||
const stopManagedAgentMutation = useStopManagedAgentMutation();
|
||||
@@ -155,6 +180,7 @@ export function useMembersSidebarActions({
|
||||
// agent-wide deploy/!shutdown flow below.
|
||||
if (agent.backend.type === "local" && relayUrl) {
|
||||
const action = managedAgentPairAction(runtime);
|
||||
assertStartNotBlockedByPresence(agent, action === "stop");
|
||||
await runtimeActionMutation.mutateAsync({
|
||||
action,
|
||||
pubkey: agent.pubkey,
|
||||
@@ -188,6 +214,7 @@ export function useMembersSidebarActions({
|
||||
return;
|
||||
}
|
||||
|
||||
assertStartNotBlockedByPresence(agent, false);
|
||||
await startManagedAgentWithRules({
|
||||
agent,
|
||||
startManagedAgent: startManagedAgentMutation.mutateAsync,
|
||||
@@ -205,6 +232,7 @@ export function useMembersSidebarActions({
|
||||
async function handleRespawnAll() {
|
||||
await runBulkAgentAction({
|
||||
action: async (agent) => {
|
||||
assertStartNotBlockedByPresence(agent, isManagedAgentActive(agent));
|
||||
await respawnManagedAgentWithRules({
|
||||
agent,
|
||||
startManagedAgent: startManagedAgentMutation.mutateAsync,
|
||||
|
||||
@@ -2,7 +2,11 @@ import * as React from "react";
|
||||
import { ChevronDown, ChevronUp, Pencil } from "lucide-react";
|
||||
|
||||
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
|
||||
import { getManagedAgentPrimaryActionLabel } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import { agentPresenceStartBlockReason } from "@/features/agents/lib/useAgentAvailability";
|
||||
import {
|
||||
getManagedAgentPrimaryActionLabel,
|
||||
isManagedAgentActive,
|
||||
} from "@/features/agents/lib/managedAgentControlActions";
|
||||
import { RestartDiffBadge } from "@/features/agents/ui/RestartDiffBadge";
|
||||
import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel";
|
||||
import type { IdentityArchiveActions } from "@/features/identity-archive/hooks";
|
||||
@@ -417,6 +421,14 @@ export function ProfileSummaryView({
|
||||
concealed={primaryActionsConcealed}
|
||||
followMutation={followMutation}
|
||||
agentActionDisabled={isAgentActionPending}
|
||||
agentStartBlockReason={
|
||||
managedAgent
|
||||
? agentPresenceStartBlockReason(
|
||||
isManagedAgentActive(managedAgent),
|
||||
presenceStatus,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
agentActionLabel={
|
||||
isOwner === true && managedAgent
|
||||
? getManagedAgentPrimaryActionLabel(managedAgent)
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Spinner } from "@/shared/ui/spinner";
|
||||
export function ProfilePrimaryActions({
|
||||
actionGroupRef,
|
||||
agentActionDisabled,
|
||||
agentStartBlockReason,
|
||||
agentActionLabel,
|
||||
agentActionLive,
|
||||
className,
|
||||
@@ -44,6 +45,7 @@ export function ProfilePrimaryActions({
|
||||
}: {
|
||||
actionGroupRef?: Ref<HTMLDivElement>;
|
||||
agentActionDisabled?: boolean;
|
||||
agentStartBlockReason?: string;
|
||||
agentActionLabel?: string;
|
||||
agentActionLive?: boolean;
|
||||
className?: string;
|
||||
@@ -94,7 +96,8 @@ export function ProfilePrimaryActions({
|
||||
{onAgentPrimaryAction && agentActionLabel ? (
|
||||
<ProfileActionTile
|
||||
active
|
||||
disabled={agentActionDisabled}
|
||||
disabled={agentActionDisabled || Boolean(agentStartBlockReason)}
|
||||
title={agentStartBlockReason}
|
||||
icon={agentActionLive ? Square : Play}
|
||||
label={agentActionLabel}
|
||||
onClick={onAgentPrimaryAction}
|
||||
@@ -216,6 +219,7 @@ function ProfileActionTile({
|
||||
label,
|
||||
onClick,
|
||||
testId,
|
||||
title,
|
||||
}: {
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -224,6 +228,7 @@ function ProfileActionTile({
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
@@ -234,6 +239,7 @@ function ProfileActionTile({
|
||||
active && "bg-foreground text-background hover:bg-foreground/90",
|
||||
)}
|
||||
data-testid={testId}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
startManagedAgentWithRules,
|
||||
stopManagedAgentWithRules,
|
||||
} from "@/features/agents/lib/managedAgentControlActions";
|
||||
import {
|
||||
agentPresenceStartBlockReason,
|
||||
useAgentAvailability,
|
||||
} from "@/features/agents/lib/useAgentAvailability";
|
||||
import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks";
|
||||
import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types";
|
||||
|
||||
@@ -23,6 +27,7 @@ export function useAgentLifecycleActions({
|
||||
startManagedAgent: (pubkey: string) => Promise<unknown>;
|
||||
stopManagedAgent: (pubkey: string) => Promise<unknown>;
|
||||
}) {
|
||||
const { status: availability } = useAgentAvailability(managedAgent?.pubkey);
|
||||
const handleAgentPrimaryAction = React.useCallback(async () => {
|
||||
if (!managedAgent) return;
|
||||
|
||||
@@ -41,6 +46,8 @@ export function useAgentLifecycleActions({
|
||||
return;
|
||||
}
|
||||
|
||||
const blockReason = agentPresenceStartBlockReason(false, availability);
|
||||
if (blockReason) throw new Error(blockReason);
|
||||
await startManagedAgentWithRules({
|
||||
agent: managedAgent,
|
||||
startManagedAgent,
|
||||
@@ -56,6 +63,7 @@ export function useAgentLifecycleActions({
|
||||
);
|
||||
}
|
||||
}, [
|
||||
availability,
|
||||
channels,
|
||||
managedAgent,
|
||||
relayAgents,
|
||||
@@ -67,6 +75,11 @@ export function useAgentLifecycleActions({
|
||||
if (!managedAgent) return;
|
||||
|
||||
try {
|
||||
const blockReason = agentPresenceStartBlockReason(
|
||||
isManagedAgentActive(managedAgent),
|
||||
availability,
|
||||
);
|
||||
if (blockReason) throw new Error(blockReason);
|
||||
await respawnManagedAgentWithRules({
|
||||
agent: managedAgent,
|
||||
startManagedAgent,
|
||||
@@ -79,7 +92,7 @@ export function useAgentLifecycleActions({
|
||||
error instanceof Error ? error.message : "Agent restart failed.",
|
||||
);
|
||||
}
|
||||
}, [managedAgent, startManagedAgent, stopManagedAgent]);
|
||||
}, [availability, managedAgent, startManagedAgent, stopManagedAgent]);
|
||||
|
||||
return { handleAgentPrimaryAction, handleAgentRestart };
|
||||
}
|
||||
|
||||
@@ -189,3 +189,162 @@ test("missing snapshot is offline but failed reads cannot reuse cached online",
|
||||
).toHaveAttribute("aria-label", "Stop");
|
||||
}
|
||||
});
|
||||
|
||||
test("stopped local with authored presence has a dot, not an invokable Start", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: LOCAL,
|
||||
name: "Present local",
|
||||
status: "stopped",
|
||||
channelNames: ["agents"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/#/agents");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
||||
channelName: "agents",
|
||||
kind: 20001,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.evaluate(
|
||||
(pubkey) =>
|
||||
window.__BUZZ_E2E_EMIT_MOCK_PRESENCE__?.({ pubkey, status: "online" }),
|
||||
LOCAL,
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId(`agent-runtime-active-${LOCAL}`),
|
||||
).toHaveAttribute("aria-label", "Present local: Online");
|
||||
await expect(page.getByTestId(`agent-runtime-start-${LOCAL}`)).toHaveCount(0);
|
||||
await page
|
||||
.getByRole("button", { name: "Present local agent profile" })
|
||||
.press("Enter");
|
||||
const action = page.getByTestId("user-profile-agent-primary-action");
|
||||
await expect(action).toHaveAttribute("aria-label", "Start agent");
|
||||
await expect(action).toBeDisabled();
|
||||
await action.evaluate((button: HTMLButtonElement) => button.click());
|
||||
await action.press("Enter");
|
||||
await action.press("Space");
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
(window.__BUZZ_E2E_COMMANDS__ ?? []).filter((command) =>
|
||||
["start_managed_agent", "stop_managed_agent"].includes(command),
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
await expect(page.getByTestId("user-profile-agent-restart")).toHaveCount(0);
|
||||
const badge = page.getByTestId("user-profile-presence-badge");
|
||||
await expect(badge).toHaveAttribute("aria-label", "Online");
|
||||
await waitForAnimations(page);
|
||||
await page
|
||||
.getByTestId(`managed-agent-${LOCAL}`)
|
||||
.screenshot({ path: testInfo.outputPath("stopped-online-card.png") });
|
||||
await page
|
||||
.getByTestId("user-profile-panel")
|
||||
.screenshot({ path: testInfo.outputPath("stopped-online-profile.png") });
|
||||
for (const status of ["away", "offline"] as const) {
|
||||
await page.evaluate(
|
||||
({ pubkey, status }) =>
|
||||
window.__BUZZ_E2E_EMIT_MOCK_PRESENCE__?.({ pubkey, status }),
|
||||
{ pubkey: LOCAL, status },
|
||||
);
|
||||
await expect(badge).toHaveAttribute(
|
||||
"aria-label",
|
||||
status === "away" ? "Away" : "Offline",
|
||||
);
|
||||
if (status === "away") {
|
||||
await expect(
|
||||
page.getByTestId(`agent-runtime-active-${LOCAL}`),
|
||||
).toHaveAttribute("aria-label", "Present local: Away");
|
||||
await expect(action).toBeDisabled();
|
||||
} else {
|
||||
await expect(action).toBeEnabled();
|
||||
await expect(
|
||||
page.getByTestId(`agent-runtime-start-${LOCAL}`),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
// Keyboard activation is restored for the ordinary stopped/offline control.
|
||||
// Runtime startup alone must still not manufacture authored availability.
|
||||
await action.press("Enter");
|
||||
await expect(action).toHaveAttribute("aria-label", "Stop");
|
||||
await expect(badge).toHaveAttribute("aria-label", "Offline");
|
||||
await expect(
|
||||
page.getByTestId(`agent-runtime-active-${LOCAL}`),
|
||||
).toHaveAttribute("aria-label", "Present local: Offline");
|
||||
});
|
||||
|
||||
test("member menu cannot start a present stopped local runtime", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: LOCAL,
|
||||
name: "Present member",
|
||||
status: "stopped",
|
||||
channelNames: ["agents"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-agents").click();
|
||||
await page.getByTestId("channel-members-trigger").click();
|
||||
const row = page.getByTestId(`sidebar-member-${LOCAL}`);
|
||||
await expect(row).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
||||
channelName: "agents",
|
||||
kind: 20001,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.evaluate(
|
||||
(pubkey) =>
|
||||
window.__BUZZ_E2E_EMIT_MOCK_PRESENCE__?.({ pubkey, status: "away" }),
|
||||
LOCAL,
|
||||
);
|
||||
await row.hover();
|
||||
const menu = page.getByTestId(`sidebar-member-menu-${LOCAL}`);
|
||||
await menu.focus();
|
||||
await menu.press("Enter");
|
||||
const action = page.getByTestId(`sidebar-agent-action-${LOCAL}`);
|
||||
await expect(action).toContainText("Start");
|
||||
await expect(action).toHaveAttribute("aria-disabled", "true");
|
||||
await action.press("Enter");
|
||||
await action.evaluate((node: HTMLElement) => node.click());
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
(window.__BUZZ_E2E_COMMANDS__ ?? []).filter((command) =>
|
||||
/^(start|stop)_managed_agent/.test(command),
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
await page.evaluate(
|
||||
(pubkey) =>
|
||||
window.__BUZZ_E2E_EMIT_MOCK_PRESENCE__?.({ pubkey, status: "offline" }),
|
||||
LOCAL,
|
||||
);
|
||||
await expect(action).not.toHaveAttribute("aria-disabled", "true");
|
||||
await action.click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window.__BUZZ_E2E_COMMANDS__ ?? []).filter(
|
||||
(command) => command === "start_managed_agent_runtime",
|
||||
).length,
|
||||
),
|
||||
)
|
||||
.toBe(1);
|
||||
});
|
||||
|
||||
@@ -12,12 +12,31 @@ remain separate: a deployed provider agent still offers Shutdown while offline.
|
||||
Shutdown sends a request, not a confirmed termination, and absence of presence
|
||||
is not permission to deploy a duplicate body. Local Stop/Start routing is unchanged.
|
||||
|
||||
A locally stopped record with current exact-key Online or Away presence does
|
||||
not establish local process ownership. Its card replaces Start (including a
|
||||
stale restart/error badge) with the presence dot; its profile and member menu
|
||||
retain the lifecycle-derived Start label but disable activation with an
|
||||
explanation. No Stop is invented. Local/pair-owned running controls keep their
|
||||
existing Stop/Restart routing, even when presence is Offline. The same positive
|
||||
presence guard covers list/profile callbacks and member bulk respawn so the
|
||||
visible affordance is not the only fence. This is a UI startup guard, **not** a
|
||||
distributed singleton lock: missing, expired, failed or disconnected presence
|
||||
cannot certify that starting another body is safe. Existing relay query/live
|
||||
subscription freshness remains authoritative; no separate heartbeat or cache
|
||||
is introduced. Runtime details remain accessible through the card/profile.
|
||||
|
||||
|
||||
## Regression evidence
|
||||
|
||||
- `desktop/src/features/agents/lib/useAgentAvailability.test.mjs`: successful,
|
||||
missing, unavailable, and disconnected presence; lifecycle routing and badges.
|
||||
- `desktop/tests/e2e/agent-availability.spec.ts`: deployed/offline profile and
|
||||
card, live presence and disconnected-state behavior.
|
||||
card, stopped/Online and Away presence, disabled keyboard/click Start in
|
||||
profile and member menu, restored Offline startup, running-but-Offline,
|
||||
live updates and disconnected-state behavior.
|
||||
- `desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs`:
|
||||
mounted persona/custom/unknown cards, exact-key versus other-key presence,
|
||||
Online/Away/offline/missing transitions and actual Start callback activation.
|
||||
- `desktop/tests/e2e/agents.spec.ts`: Start badge animation and avatar continuity,
|
||||
runtime-only negative control, then authored Online presence.
|
||||
- `desktop/tests/e2e/profile.spec.ts`: created-agent initial Online snapshot and
|
||||
|
||||
Reference in New Issue
Block a user