diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2..d8903f2966 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { startManagedAgentWithRules, respawnManagedAgentWithRules, + shouldOfferImmediateRespondToRestart, } from "./managedAgentControlActions.ts"; function agent(overrides = {}) { @@ -166,3 +167,34 @@ test("test_respawn_onStopped_fires_before_start_resolves", async () => { "onStopped must fire after stop resolves and before start is called", ); }); + +test("test_shouldOfferImmediateRespondToRestart_fires_only_when_gate_changed_and_needs_restart", () => { + assert.equal( + shouldOfferImmediateRespondToRestart({ respondTo: "anyone" }, true), + true, + "respond-to changed + backend confirms drift → offer restart", + ); + assert.equal( + shouldOfferImmediateRespondToRestart( + { respondToAllowlist: ["a".repeat(64)] }, + true, + ), + true, + "allowlist-only change + drift → offer restart", + ); + assert.equal( + shouldOfferImmediateRespondToRestart({ respondTo: "anyone" }, false), + false, + "backend says no drift (e.g. agent already stopped) → never prompt", + ); + assert.equal( + shouldOfferImmediateRespondToRestart({ name: "New Name" }, true), + false, + "unrelated field changed (name) → never prompt for a respond-to reason", + ); + assert.equal( + shouldOfferImmediateRespondToRestart({}, true), + false, + "no fields changed at all → never prompt", + ); +}); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index dbaaaba803..c0b2583c1b 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -35,6 +35,28 @@ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } +/** + * Decide whether a just-saved edit should offer an immediate restart because + * it changed the inbound author gate ("Who can talk to this agent"). + * + * `respond_to`/`respond_to_allowlist` control who may address the agent, but + * a running process keeps its OLD gate until restarted — `update_managed_agent` + * never auto-restarts, and the passive "Restart required" badge is easy to + * miss for a setting whose whole point is to take effect promptly (buzz#2501, + * buzz#2950: agents set to `anyone`/`allowlist` stayed unreachable to + * non-owners because the running harness never picked up the change). Only + * fires when the save actually touched the gate AND the backend confirms the + * running instance's config now differs from what it's live with. + */ +export function shouldOfferImmediateRespondToRestart( + input: { respondTo?: unknown; respondToAllowlist?: unknown }, + needsRestart: boolean, +) { + const respondToChanged = + input.respondTo !== undefined || input.respondToAllowlist !== undefined; + return respondToChanged && needsRestart; +} + export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { if (agent.backend.type === "provider") { return isManagedAgentActive(agent) ? "Shutdown" : "Deploy"; diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index f3c410e2ff..a21896e9e9 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { toast } from "sonner"; import { useAcpRuntimesQuery, @@ -83,6 +82,10 @@ import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { resolveModelFieldStatusMessage } from "./agentConfigControls"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; import { showAgentProfileSyncWarning } from "./agentProfileSyncWarning"; +import { + offerRespondToRestartIfNeeded, + offerStartNowPrompt, +} from "./agentSaveOutcomePrompts"; import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog"; import { ADD_CUSTOM_HARNESS_OPTION, @@ -737,28 +740,10 @@ export function AgentInstanceEditDialog({ showAgentProfileSyncWarning(result.agent.name, result.profileSyncError); handleOpenChange(false); onUpdated?.(result.agent); - // The auto-restart policy deliberately never fires for a stopped or - // failing agent (a broken agent must not auto-loop), so an edit meant - // to FIX one silently waits for a manual start. Offer that start - // explicitly instead of relying on the user to know the policy. if (!isManagedAgentActive(result.agent)) { - const startedName = result.agent.name; - toast(`${startedName} saved while stopped.`, { - action: { - label: "Start now", - onClick: () => { - startMutation.mutate(result.agent.pubkey, { - onSuccess: () => toast.success(`${startedName} started.`), - onError: (error) => - toast.error( - error instanceof Error - ? `${startedName} failed to start: ${error.message}` - : `${startedName} failed to start.`, - ), - }); - }, - }, - }); + offerStartNowPrompt(result.agent, startMutation); + } else { + offerRespondToRestartIfNeeded(input, result.agent); } } catch { // React Query stores the error; keep dialog open and render it inline. diff --git a/desktop/src/features/agents/ui/agentSaveOutcomePrompts.ts b/desktop/src/features/agents/ui/agentSaveOutcomePrompts.ts new file mode 100644 index 0000000000..f77b37acc9 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSaveOutcomePrompts.ts @@ -0,0 +1,87 @@ +import { toast } from "sonner"; + +import type { useStartManagedAgentMutation } from "@/features/agents/hooks"; +import { + respawnManagedAgentWithRules, + shouldOfferImmediateRespondToRestart, +} from "@/features/agents/lib/managedAgentControlActions"; +import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks"; +import { + startManagedAgent, + stopManagedAgent, +} from "@/shared/api/tauriManagedAgents"; +import type { ManagedAgent, UpdateManagedAgentInput } from "@/shared/api/types"; + +/** + * After a managed-agent edit saves, offer an immediate restart if the save + * changed "Who can talk to this agent" on a still-running instance. + * + * `respond_to`/`respond_to_allowlist` is a security-relevant gate: the + * running process keeps its OLD env until restarted (`update_managed_agent` + * never auto-restarts), and the passive "Restart required" badge is easy to + * miss for a setting whose whole point is to take effect promptly. This + * mirrors the existing "saved while stopped → Start now" toast pattern in + * `AgentInstanceEditDialog`, but for the running/needs-restart case (buzz#2501, + * buzz#2950: agents set to `anyone`/`allowlist` stayed unreachable to + * non-owners because the running harness never picked up the change). + */ +export function offerRespondToRestartIfNeeded( + input: Pick, + agent: ManagedAgent, +) { + if (!shouldOfferImmediateRespondToRestart(input, agent.needsRestart)) { + return; + } + + const restartedName = agent.name; + toast(`${restartedName} saved — restart to apply the new setting.`, { + action: { + label: "Restart now", + onClick: () => { + respawnManagedAgentWithRules({ + agent, + startManagedAgent, + stopManagedAgent, + onStopped: () => clearActiveTurnsForAgentOnStop(agent.pubkey), + }) + .then(() => toast.success(`${restartedName} restarted.`)) + .catch((error: unknown) => + toast.error( + error instanceof Error + ? `${restartedName} failed to restart: ${error.message}` + : `${restartedName} failed to restart.`, + ), + ); + }, + }, + }); +} + +/** + * The auto-restart policy deliberately never fires for a stopped or failing + * agent (a broken agent must not auto-loop), so an edit meant to FIX one + * silently waits for a manual start. Offer that start explicitly instead of + * relying on the user to know the policy. + */ +export function offerStartNowPrompt( + agent: ManagedAgent, + startMutation: ReturnType, +) { + const startedName = agent.name; + toast(`${startedName} saved while stopped.`, { + action: { + label: "Start now", + onClick: () => { + startMutation.mutate(agent.pubkey, { + onSuccess: () => toast.success(`${startedName} started.`), + onError: (error) => + toast.error( + error instanceof Error + ? `${startedName} failed to start: ${error.message}` + : `${startedName} failed to start.`, + ), + }); + }, + }, + }); +}