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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
startManagedAgentWithRules,
respawnManagedAgentWithRules,
shouldOfferImmediateRespondToRestart,
} from "./managedAgentControlActions.ts";

function agent(overrides = {}) {
Expand Down Expand Up @@ -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",
);
});
22 changes: 22 additions & 0 deletions desktop/src/features/agents/lib/managedAgentControlActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@ export function isManagedAgentActive(agent: Pick<ManagedAgent, "status">) {
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";
Expand Down
29 changes: 7 additions & 22 deletions desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions desktop/src/features/agents/ui/agentSaveOutcomePrompts.ts
Original file line number Diff line number Diff line change
@@ -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<UpdateManagedAgentInput, "respondTo" | "respondToAllowlist">,
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<typeof useStartManagedAgentMutation>,
) {
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.`,
),
});
},
},
});
}