Skip to content
Merged
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
87 changes: 87 additions & 0 deletions desktop/src/features/agents/lib/redeployAfterEdit.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
46 changes: 46 additions & 0 deletions desktop/src/features/agents/lib/redeployAfterEdit.ts
Original file line number Diff line number Diff line change
@@ -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<ManagedAgent, "backend" | "status">,
touchesHostConfig: boolean,
): boolean {
return (
agent.backend.type === "provider" &&
isManagedAgentActive(agent) &&
touchesHostConfig
);
}
16 changes: 8 additions & 8 deletions desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
}
Expand Down
88 changes: 83 additions & 5 deletions desktop/src/features/agents/ui/agentProfileSyncWarning.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ export function useInstanceModelDefinitionWrite(input: {
isPending: boolean;
/** Awaited unconditionally by the submit path; a no-op decision performs nothing. */
perform: () => Promise<void>;
/** 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;
Expand Down Expand Up @@ -183,5 +187,6 @@ export function useInstanceModelDefinitionWrite(input: {
if (decision.kind !== "write") return;
await mutation.mutateAsync(decision.input);
},
willWrite: decision.kind === "write",
};
}
12 changes: 9 additions & 3 deletions desktop/src/features/profile/ui/UserProfilePanelSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function ProfilePrimaryActions({
agentActionDisabled,
agentActionLabel,
agentActionLive,
agentRestartLabel,
canEditAgent,
followMutation,
isFollowing,
Expand All @@ -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<typeof useFollowMutation>;
isFollowing: boolean;
Expand Down Expand Up @@ -108,7 +112,7 @@ export function ProfilePrimaryActions({
<ProfileQuickAction
disabled={agentActionDisabled}
icon={RefreshCw}
label="Restart"
label={agentRestartLabel ?? "Restart"}
onClick={onAgentRestart}
testId="user-profile-agent-restart"
/>
Expand Down
6 changes: 5 additions & 1 deletion desktop/src/features/profile/ui/useAgentLifecycleActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading