diff --git a/desktop/src/features/agents/lib/redeployAfterEdit.test.mjs b/desktop/src/features/agents/lib/redeployAfterEdit.test.mjs new file mode 100644 index 000000000..e1ad45f3d --- /dev/null +++ b/desktop/src/features/agents/lib/redeployAfterEdit.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + editTouchesHostConfig, + shouldOfferRedeployAfterEdit, +} from "./redeployAfterEdit.ts"; + +const provider = { type: "provider", id: "ssh", config: {} }; +const local = { type: "local" }; + +describe("editTouchesHostConfig", () => { + it("is false for a name-only edit", () => { + assert.equal( + editTouchesHostConfig({ pubkey: "pk", name: "New Name" }), + false, + ); + }); + + it("is false when every optional field is undefined", () => { + assert.equal( + editTouchesHostConfig({ pubkey: "pk", model: undefined }), + false, + ); + }); + + it("is true when a deploy-visible field is set", () => { + assert.equal( + editTouchesHostConfig({ pubkey: "pk", model: "gpt-5.6-terra" }), + true, + ); + assert.equal( + editTouchesHostConfig({ pubkey: "pk", envVars: { KEY: "v" } }), + true, + ); + assert.equal(editTouchesHostConfig({ pubkey: "pk", parallelism: 2 }), true); + }); + + it("counts an explicit null as a change (a cleared field)", () => { + assert.equal( + editTouchesHostConfig({ pubkey: "pk", systemPrompt: null }), + true, + ); + }); +}); + +describe("shouldOfferRedeployAfterEdit", () => { + it("offers for a deployed provider record with host-visible changes", () => { + assert.equal( + shouldOfferRedeployAfterEdit( + { backend: provider, status: "deployed" }, + true, + ), + true, + ); + }); + + it("does not offer when the edit touched nothing the host reads", () => { + assert.equal( + shouldOfferRedeployAfterEdit( + { backend: provider, status: "deployed" }, + false, + ), + false, + ); + }); + + it("does not offer for a provider record that is not deployed", () => { + // The saved-while-stopped toast owns this case: the next deploy picks + // the edit up, so there is nothing extra to apply. + assert.equal( + shouldOfferRedeployAfterEdit( + { backend: provider, status: "not_deployed" }, + true, + ), + false, + ); + }); + + it("never offers for a local record", () => { + // The auto-restart policy covers running local agents. + assert.equal( + shouldOfferRedeployAfterEdit({ backend: local, status: "running" }, true), + false, + ); + }); +}); diff --git a/desktop/src/features/agents/lib/redeployAfterEdit.ts b/desktop/src/features/agents/lib/redeployAfterEdit.ts new file mode 100644 index 000000000..223dd00b9 --- /dev/null +++ b/desktop/src/features/agents/lib/redeployAfterEdit.ts @@ -0,0 +1,46 @@ +import type { ManagedAgent, UpdateManagedAgentInput } from "@/shared/api/types"; +import { isManagedAgentActive } from "./managedAgentControlActions"; + +/** + * Whether a saved instance edit changes anything the remote host reads. + * + * Save writes the local record only. A provider-backed record's host copy + * changes on the next deploy — the SSH provider rewrites the unit's env file + * and restarts it — so an edit that touches deploy-visible config leaves the + * running remote agent on stale settings until someone redeploys. + * + * `pubkey` is the record key and `name` syncs to the relay immediately via + * the kind:0 re-publish inside the update command, so neither needs a + * redeploy. Every other field in the update patch reaches the host through + * the deploy payload (directly, or — for a linked record — through the + * definition the payload re-resolves). An explicit `null` is a real change + * (a cleared field); only `undefined` means "not part of this edit". + */ +export function editTouchesHostConfig(input: UpdateManagedAgentInput): boolean { + const { pubkey: _pubkey, name: _name, ...hostVisible } = input; + return Object.values(hostVisible).some((value) => value !== undefined); +} + +/** + * Whether the post-save toast should offer a redeploy rather than a start. + * + * True only for a provider-backed record that is currently deployed AND whose + * edit touched host-visible config. A never-deployed or shut-down provider + * record keeps the ordinary saved-while-stopped offer — its next deploy picks + * the edit up anyway — and a local record is covered by the auto-restart + * policy. + * + * The redeploy is offered, never automatic: deploy restarts the remote unit + * unconditionally, and the local auto-restart policy's mid-turn safety gates + * (working signal, quiescence window) have no remote equivalent yet. + */ +export function shouldOfferRedeployAfterEdit( + agent: Pick, + touchesHostConfig: boolean, +): boolean { + return ( + agent.backend.type === "provider" && + isManagedAgentActive(agent) && + touchesHostConfig + ); +} diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 87179a7b9..39a8d8437 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -95,10 +95,7 @@ import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { resolveModelFieldStatusMessage } from "./agentConfigControls"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; -import { - showAgentProfileSyncWarning, - showAgentSavedWhileStoppedToast, -} from "./agentProfileSyncWarning"; +import { showAgentPostSaveToasts } from "./agentProfileSyncWarning"; import { useInstanceModelDefinitionWrite } from "./instanceModelDefinitionWrite"; import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog"; import { @@ -766,12 +763,15 @@ export function AgentInstanceEditDialog({ autoRestartOnConfigChange, ); } - showAgentProfileSyncWarning(result.agent.name, result.profileSyncError); handleOpenChange(false); onUpdated?.(result.agent); - showAgentSavedWhileStoppedToast(result.agent, (pubkey, handlers) => - startMutation.mutate(pubkey, handlers), - ); + showAgentPostSaveToasts({ + agent: result.agent, + profileSyncError: result.profileSyncError, + input, + definitionModelWrite: modelDefinitionWrite.willWrite, + start: (pubkey, handlers) => startMutation.mutate(pubkey, handlers), + }); } catch { // React Query stores the error; keep dialog open and render it inline. } diff --git a/desktop/src/features/agents/ui/agentProfileSyncWarning.ts b/desktop/src/features/agents/ui/agentProfileSyncWarning.ts index 2767bca60..ee98a4416 100644 --- a/desktop/src/features/agents/ui/agentProfileSyncWarning.ts +++ b/desktop/src/features/agents/ui/agentProfileSyncWarning.ts @@ -1,7 +1,16 @@ import { toast } from "sonner"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; -import type { ManagedAgent } from "@/shared/api/types"; +import { + editTouchesHostConfig, + shouldOfferRedeployAfterEdit, +} from "@/features/agents/lib/redeployAfterEdit"; +import type { ManagedAgent, UpdateManagedAgentInput } from "@/shared/api/types"; + +type StartHandler = ( + pubkey: string, + handlers: { onSuccess: () => void; onError: (error: unknown) => void }, +) => void; export function showAgentProfileSyncWarning( agentName: string, @@ -24,10 +33,7 @@ export function showAgentProfileSyncWarning( */ export function showAgentSavedWhileStoppedToast( agent: ManagedAgent, - start: ( - pubkey: string, - handlers: { onSuccess: () => void; onError: (error: unknown) => void }, - ) => void, + start: StartHandler, ) { if (isManagedAgentActive(agent)) return; const name = agent.name; @@ -47,3 +53,75 @@ export function showAgentSavedWhileStoppedToast( }, }); } + +/** + * Offer a redeploy after saving host-visible changes to a deployed + * provider-backed agent. + * + * Save writes the local record only; the remote unit keeps running with the + * env file from its last deploy. Neither of the two apply mechanisms covers + * this case — the auto-restart policy is local-only, and the saved-while- + * stopped offer above bails because "deployed" counts as active — so without + * this prompt the edit looks applied while the host runs the old config. + * + * The start command IS the redeploy for a provider record: it rebuilds the + * payload from current state and the SSH provider rewrites the env file and + * restarts the unit. Offered rather than automatic, because that restart is + * unconditional and a mid-turn agent must not be killed silently. + */ +export function showRemoteAgentSavedToast( + agent: ManagedAgent, + start: StartHandler, +) { + const name = agent.name; + toast(`${name} saved. The host applies changes on redeploy.`, { + action: { + label: "Redeploy now", + onClick: () => + start(agent.pubkey, { + onSuccess: () => toast.success(`${name} redeployed.`), + onError: (error) => + toast.error( + error instanceof Error + ? `${name} redeploy failed: ${error.message}` + : `${name} redeploy failed.`, + ), + }), + }, + }); +} + +/** + * Every toast a successful instance-dialog save can produce, in one place — + * one owner, so the dialog stays a thin caller and the redeploy-offer gate + * lives beside the copy it selects. + * + * The relay-profile warning fires first (independent of backend), then + * exactly one of the two offers: the redeploy offer for a deployed + * provider-backed record whose edit touched deploy-visible config, otherwise + * the saved-while-stopped offer (itself a no-op while the agent is active). + */ +export function showAgentPostSaveToasts({ + agent, + profileSyncError, + input, + definitionModelWrite, + start, +}: { + agent: ManagedAgent; + profileSyncError: string | null; + /** The record patch that was just saved; decides host-visibility. */ + input: UpdateManagedAgentInput; + /** True when the save also wrote the model to the linked definition — a + * host-visible change even though the record patch omits `model`. */ + definitionModelWrite: boolean; + start: StartHandler; +}) { + showAgentProfileSyncWarning(agent.name, profileSyncError); + const hostEdit = editTouchesHostConfig(input) || definitionModelWrite; + if (shouldOfferRedeployAfterEdit(agent, hostEdit)) { + showRemoteAgentSavedToast(agent, start); + return; + } + showAgentSavedWhileStoppedToast(agent, start); +} diff --git a/desktop/src/features/agents/ui/instanceModelDefinitionWrite.ts b/desktop/src/features/agents/ui/instanceModelDefinitionWrite.ts index dfbf61708..01897cc9f 100644 --- a/desktop/src/features/agents/ui/instanceModelDefinitionWrite.ts +++ b/desktop/src/features/agents/ui/instanceModelDefinitionWrite.ts @@ -149,6 +149,10 @@ export function useInstanceModelDefinitionWrite(input: { isPending: boolean; /** Awaited unconditionally by the submit path; a no-op decision performs nothing. */ perform: () => Promise; + /** True when Save will write the model to the linked definition — a + * host-visible change even though the record patch itself omits `model`, + * so the post-save redeploy offer must count it. */ + willWrite: boolean; } { const { isProviderRecord, personaId, linkedPersona, model, originalModel } = input; @@ -183,5 +187,6 @@ export function useInstanceModelDefinitionWrite(input: { if (decision.kind !== "write") return; await mutation.mutateAsync(decision.input); }, + willWrite: decision.kind === "write", }; } diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 057376a38..8cae7f793 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -370,14 +370,20 @@ export function ProfileSummaryView({ ? handleAgentPrimaryAction : undefined } + // Restart covers both backends: local respawns the process, + // provider re-deploys (the SSH provider rewrites the unit's env + // file and restarts it) — the only way saved edits reach a + // running remote agent. onAgentRestart={ isOwner === true && - managedAgent?.backend.type === "local" && - (managedAgent.status === "running" || - managedAgent.status === "deployed") + (managedAgent?.status === "running" || + managedAgent?.status === "deployed") ? handleAgentRestart : undefined } + agentRestartLabel={ + managedAgent?.backend.type === "provider" ? "Redeploy" : "Restart" + } isFollowing={isFollowing} messagePending={isMessagePending} onMessage={onOpenDm ? handleMessage : undefined} diff --git a/desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx b/desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx index f03635ff1..3d3ad4e20 100644 --- a/desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx +++ b/desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx @@ -26,6 +26,7 @@ export function ProfilePrimaryActions({ agentActionDisabled, agentActionLabel, agentActionLive, + agentRestartLabel, canEditAgent, followMutation, isFollowing, @@ -41,6 +42,9 @@ export function ProfilePrimaryActions({ agentActionDisabled?: boolean; agentActionLabel?: string; agentActionLive?: boolean; + /** "Restart" for a local process; "Redeploy" for a provider-backed record, + * where the same action re-deploys to the host instead. */ + agentRestartLabel?: string; canEditAgent: boolean; followMutation: ReturnType; isFollowing: boolean; @@ -108,7 +112,7 @@ export function ProfilePrimaryActions({ diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts index 62d0d3c7a..633e5aafa 100644 --- a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -73,7 +73,11 @@ export function useAgentLifecycleActions({ stopManagedAgent, onStopped: () => clearActiveTurnsForAgentOnStop(managedAgent.pubkey), }); - toast.success(`Restarted ${managedAgent.name}.`); + toast.success( + managedAgent.backend.type === "provider" + ? `Redeployed ${managedAgent.name}.` + : `Restarted ${managedAgent.name}.`, + ); } catch (error) { toast.error( error instanceof Error ? error.message : "Agent restart failed.",