diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 21b5794995..2033003bc8 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -478,6 +478,25 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe record.model = snapshot.model; record.provider = snapshot.provider; record.runtime = snapshot.runtime; + // Drop a stale create-time harness pin when the definition names a + // different known runtime; custom commands stay pinned. + if let Some(def_runtime) = persona + .runtime + .as_deref() + .map(str::trim) + .filter(|r| !r.is_empty()) + .and_then(crate::managed_agents::known_acp_runtime_exact) + { + if let Some(pin_runtime) = record + .agent_command_override + .as_deref() + .and_then(crate::managed_agents::known_acp_runtime) + { + if !std::ptr::eq(pin_runtime, def_runtime) { + record.agent_command_override = None; + } + } + } // env_vars stay overrides-only. Self-heal records written before the env // refresh: persona env used to be baked into `record.env_vars`, turning // inherited values into pseudo-overrides that shadow later persona edits. diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 1f53a365f3..203863b0ed 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -366,21 +366,39 @@ fn definition_runtime_edit_changes_hash_for_materialized_record() { ); } -/// (c) An explicit agent_command_override (ladder step 1) must beat a -/// changed definition runtime — the badge must NOT fire for a pinned instance. +/// (c) A pin naming a KNOWN runtime no longer beats a changed definition +/// runtime — apply_persona_snapshot clears the stale pin, so the badge fires. #[test] -fn agent_command_override_beats_definition_runtime_change() { +fn known_runtime_pin_yields_to_definition_runtime_change() { let mut rec = record(); rec.persona_id = Some("pers".into()); rec.runtime = Some("goose".into()); // materialized runtime - rec.agent_command_override = Some("goose".into()); // explicit per-instance pin + rec.agent_command_override = Some("goose".into()); // create-time pin + + let before = [persona("pers", Some("goose"), "prompt")]; + let after = [persona("pers", Some("claude"), "prompt")]; + assert_ne!( + spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + "stale known-runtime pin must not shadow a definition runtime edit" + ); +} + +/// (c2) A custom-command override (no matching known runtime) still beats a +/// changed definition runtime — the badge must NOT fire for such a pin. +#[test] +fn custom_command_override_beats_definition_runtime_change() { + let mut rec = record(); + rec.persona_id = Some("pers".into()); + rec.runtime = Some("goose".into()); // materialized runtime + rec.agent_command_override = Some("/opt/custom/my-agent".into()); let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_eq!( spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), - "explicit override must win regardless of definition runtime change" + "custom command override must win regardless of definition runtime change" ); } diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 09e8d51487..c1f713d230 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -32,13 +32,9 @@ import { buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, } from "@/features/agents/lib/instanceInputForDefinition"; -import { - isManagedAgentActive, - startManagedAgentWithRules, - stopManagedAgentWithRules, -} from "@/features/agents/lib/managedAgentControlActions"; import { describeLogFile } from "@/features/agents/ui/agentUi"; import { AgentDialog } from "@/features/agents/ui/AgentDialog"; +import { useAgentLifecycleActions } from "@/features/profile/ui/useAgentLifecycleActions"; import { consumePendingOpenEditAgent, type EditAgentFocusTarget, @@ -451,42 +447,14 @@ export function UserProfilePanel({ ], ); - const handleAgentPrimaryAction = React.useCallback(async () => { - if (!managedAgent) return; - - try { - if (isManagedAgentActive(managedAgent)) { - const result = await stopManagedAgentWithRules({ - agent: managedAgent, - channels: channelsQuery.data ?? [], - relayAgents: relayAgentsQuery.data ?? [], - stopManagedAgent: stopAgentMutation.mutateAsync, - }); - toast.success(result.noticeMessage ?? `Stopped ${managedAgent.name}.`); - return; - } - - await startManagedAgentWithRules({ - agent: managedAgent, - startManagedAgent: startAgentMutation.mutateAsync, - }); - toast.success( - managedAgent.backend.type === "provider" - ? `Deploying ${managedAgent.name}.` - : `Started ${managedAgent.name}.`, - ); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Agent action failed.", - ); - } - }, [ - channelsQuery.data, - managedAgent, - relayAgentsQuery.data, - startAgentMutation.mutateAsync, - stopAgentMutation.mutateAsync, - ]); + const { handleAgentPrimaryAction, handleAgentRestart } = + useAgentLifecycleActions({ + channels: channelsQuery.data, + managedAgent, + relayAgents: relayAgentsQuery.data, + startManagedAgent: startAgentMutation.mutateAsync, + stopManagedAgent: stopAgentMutation.mutateAsync, + }); const handleInstantiateAgent = React.useCallback(async () => { if (!resolvedPersona) return; @@ -828,6 +796,7 @@ export function UserProfilePanel({ followMutation={followMutation} agentInstruction={agentInstruction} handleAgentPrimaryAction={handleAgentPrimaryAction} + handleAgentRestart={handleAgentRestart} handleEditAgent={handleEditAgent} handleEditPersona={canEditPersona ? handleEditPersona : undefined} handleInstantiateAgent={handleInstantiateAgent} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 2a46dcc7a5..4514b62d95 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -8,6 +8,7 @@ import { MessageSquare, Pencil, Play, + RefreshCw, Square, UserMinus, UserPlus, @@ -77,6 +78,7 @@ export type ProfileSummaryViewProps = { canInstantiateAgent: boolean; agentInstruction: string | null; handleAgentPrimaryAction: () => void; + handleAgentRestart: () => void; handleEditAgent: () => void; handleEditPersona?: () => void; handleInstantiateAgent: () => void; @@ -189,6 +191,7 @@ export function ProfileSummaryView({ canInstantiateAgent, agentInstruction, handleAgentPrimaryAction, + handleAgentRestart, handleEditAgent, handleEditPersona, handleInstantiateAgent, @@ -369,6 +372,14 @@ export function ProfileSummaryView({ ? handleAgentPrimaryAction : undefined } + onAgentRestart={ + isOwner === true && + managedAgent?.backend.type === "local" && + (managedAgent.status === "running" || + managedAgent.status === "deployed") + ? handleAgentRestart + : undefined + } isFollowing={isFollowing} messagePending={isMessagePending} onMessage={onOpenDm ? handleMessage : undefined} @@ -629,6 +640,7 @@ function ProfilePrimaryActions({ isFollowing, messagePending, onAgentPrimaryAction, + onAgentRestart, onEditAgent, onMessage, pubkey, @@ -642,6 +654,7 @@ function ProfilePrimaryActions({ isFollowing: boolean; messagePending?: boolean; onAgentPrimaryAction?: () => void; + onAgentRestart?: () => void; onEditAgent: () => void; onMessage?: () => void; pubkey: string; @@ -698,6 +711,15 @@ function ProfilePrimaryActions({ testId="user-profile-agent-primary-action" /> ) : null} + {onAgentRestart ? ( + + ) : null} ); } diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts new file mode 100644 index 0000000000..f84e149c11 --- /dev/null +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -0,0 +1,80 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + isManagedAgentActive, + respawnManagedAgentWithRules, + startManagedAgentWithRules, + stopManagedAgentWithRules, +} from "@/features/agents/lib/managedAgentControlActions"; +import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; + +export function useAgentLifecycleActions({ + channels, + managedAgent, + relayAgents, + startManagedAgent, + stopManagedAgent, +}: { + channels: readonly Channel[] | undefined; + managedAgent: ManagedAgent | undefined; + relayAgents: readonly RelayAgent[] | undefined; + startManagedAgent: (pubkey: string) => Promise; + stopManagedAgent: (pubkey: string) => Promise; +}) { + const handleAgentPrimaryAction = React.useCallback(async () => { + if (!managedAgent) return; + + try { + if (isManagedAgentActive(managedAgent)) { + const result = await stopManagedAgentWithRules({ + agent: managedAgent, + channels: channels ?? [], + relayAgents: relayAgents ?? [], + stopManagedAgent, + }); + toast.success(result.noticeMessage ?? `Stopped ${managedAgent.name}.`); + return; + } + + await startManagedAgentWithRules({ + agent: managedAgent, + startManagedAgent, + }); + toast.success( + managedAgent.backend.type === "provider" + ? `Deploying ${managedAgent.name}.` + : `Started ${managedAgent.name}.`, + ); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Agent action failed.", + ); + } + }, [ + channels, + managedAgent, + relayAgents, + startManagedAgent, + stopManagedAgent, + ]); + + const handleAgentRestart = React.useCallback(async () => { + if (!managedAgent) return; + + try { + await respawnManagedAgentWithRules({ + agent: managedAgent, + startManagedAgent, + stopManagedAgent, + }); + toast.success(`Restarted ${managedAgent.name}.`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Agent restart failed.", + ); + } + }, [managedAgent, startManagedAgent, stopManagedAgent]); + + return { handleAgentPrimaryAction, handleAgentRestart }; +}