feat(desktop): add backend selector to edit and import

Co-authored-by: npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2 <dbd8c9941ba6dafebcef0abc015b65e75d52e7452f2ce483c9c3fd4d180f2504@sprout-oss.stage.blox.sqprod.co>

Signed-off-by: npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2 <dbd8c9941ba6dafebcef0abc015b65e75d52e7452f2ce483c9c3fd4d180f2504@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap
2026-07-30 11:40:35 -07:00
co-authored by npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2
parent 6e419b9f1c
commit 6e6c8aa307
18 changed files with 394 additions and 64 deletions
@@ -0,0 +1,116 @@
use std::collections::HashMap;
use tauri::AppHandle;
use crate::{
app_state::AppState,
managed_agents::{
build_managed_agent_summary, load_managed_agents, load_personas,
stop_managed_agent_process, BackendKind, ManagedAgentPairRuntime, ManagedAgentRecord,
ManagedAgentRuntimeKey, ManagedAgentSummary,
},
};
pub(super) fn requested_backend_update(
current: &BackendKind,
backend_agent_id: Option<&str>,
requested: Option<BackendKind>,
) -> Result<Option<BackendKind>, String> {
let Some(requested) = requested else {
return Ok(None);
};
if requested == BackendKind::Local
&& *current != BackendKind::Local
&& backend_agent_id.is_some()
{
return Err(
"cannot move a deployed provider agent to local: the provider protocol does not support undeploy"
.to_string(),
);
}
Ok(Some(requested))
}
pub(super) fn apply_backend_update(
app: &AppHandle,
record: &mut ManagedAgentRecord,
runtimes: &mut HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>,
requested: Option<BackendKind>,
) -> Result<Option<BackendKind>, String> {
let Some(backend) = requested_backend_update(
&record.backend,
record.backend_agent_id.as_deref(),
requested,
)?
else {
return Ok(None);
};
if record.backend == BackendKind::Local && backend != BackendKind::Local {
stop_managed_agent_process(app, record, runtimes)?;
}
record.provider_binary_path = match &backend {
BackendKind::Provider { config, id } => {
crate::managed_agents::validate_provider_config(config)?;
Some(
crate::managed_agents::resolve_provider_binary(id)?
.display()
.to_string(),
)
}
BackendKind::Local => None,
};
if backend != record.backend {
record.backend_agent_id = None;
}
record.start_on_app_launch = backend == BackendKind::Local;
record.backend = backend;
Ok(match &record.backend {
BackendKind::Provider { .. } => Some(record.backend.clone()),
BackendKind::Local => None,
})
}
pub(super) async fn deploy_updated_backend(
app: &AppHandle,
state: &AppState,
pubkey: &str,
backend: Option<BackendKind>,
fallback: ManagedAgentSummary,
) -> Result<ManagedAgentSummary, String> {
let Some(BackendKind::Provider { id, config }) = backend else {
return Ok(fallback);
};
let agent_json = {
let _guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(app)?;
let record = records
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
super::agents::build_deploy_payload(app, state, record)?
};
super::agents::deploy_to_provider(app, state, pubkey, &id, &config, agent_json, None).await?;
let _guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(app)?;
let runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let record = records
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
build_managed_agent_summary(
app,
record,
&runtimes,
&load_personas(app).unwrap_or_default(),
&crate::managed_agents::load_global_agent_config(app).unwrap_or_default(),
)
}
+26 -26
View File
@@ -1,9 +1,3 @@
use std::collections::{BTreeMap, HashSet};
use nostr::Keys;
use serde::Deserialize;
use tauri::{AppHandle, State};
use super::agent_model_process::run_agent_models_command;
// The map-only lookup is reached solely from the base-URL helpers that exist for
// their unit tests; discovery itself always goes through the process-env variant.
@@ -13,7 +7,6 @@ use super::agent_models_env::{
effective_discovery_provider, env_or_process_value, redaction_env_with_value, DiscoveryProvider,
};
use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRollback};
use crate::{
app_state::AppState,
managed_agents::{
@@ -27,7 +20,10 @@ use crate::{
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
};
use nostr::Keys;
use serde::Deserialize;
use std::collections::{BTreeMap, HashSet};
use tauri::{AppHandle, State};
/// Query available models from an agent via `buzz-acp models --json`.
///
/// Spawns a short-lived subprocess (no relay connection needed). The subprocess
@@ -56,35 +52,28 @@ pub async fn get_agent_models(
for pubkey in &exited_pubkeys {
state.clear_agent_session_caches(pubkey);
}
let record = records
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
let resolved = resolve_command(&record.acp_command)
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;
// Resolve the effective harness from the linked persona (mirrors spawn),
// so model discovery runs against the persona's current harness, not the
// frozen record snapshot. An explicit per-agent override wins.
let personas = load_personas(&app).unwrap_or_default();
let global = load_global_agent_config(&app).unwrap_or_default();
// Single pure helper — descriptor + authoritative model/provider
// resolver, packaged so the linked-agent regression test binds the
// exact values this command consumes. Returns Err on dangling harness
// id, propagating it to the caller.
let discovery = agent_model_discovery_config(record, &personas, &global)
.map_err(|e| model_discovery_error(&pubkey, &e))?;
let resolved_agent = resolve_command(&discovery.command)
.map(|p| p.display().to_string())
.unwrap_or_else(|| discovery.command.clone());
(resolved, resolved_agent, discovery)
}; // store lock released — subprocess runs without holding the lock
let AgentModelDiscoveryConfig {
args: agent_args,
model: persisted_model,
@@ -93,7 +82,6 @@ pub async fn get_agent_models(
env: merged_env,
command: _,
} = discovery;
let merged_env = discovery_env_with_baked_floor(merged_env);
// Resolve against the baked/process env when the record saved no provider,
// so a build-provided provider still gets live discovery.
@@ -109,7 +97,6 @@ pub async fn get_agent_models(
{
return Ok(models);
}
if let Some(models) = discover_openai_compatible_models(
&state.http_client,
&effective_provider,
@@ -120,7 +107,6 @@ pub async fn get_agent_models(
{
return Ok(models);
}
if let Some(models) = discover_anthropic_models(
&state.http_client,
&effective_provider,
@@ -131,7 +117,6 @@ pub async fn get_agent_models(
{
return Ok(models);
}
if let Some(models) = discover_databricks_models(
&state.http_client,
&effective_provider,
@@ -142,7 +127,6 @@ pub async fn get_agent_models(
{
return Ok(models);
}
run_agent_models_command(
resolved_acp,
agent_command,
@@ -813,8 +797,9 @@ pub async fn update_managed_agent(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<UpdateManagedAgentResponse, String> {
let pubkey = input.pubkey.clone();
// Phase 1: local save (synchronous, under lock)
let (summary, sync_params, rollback) = {
let (summary, sync_params, rollback, provider_deploy) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -830,9 +815,8 @@ pub async fn update_managed_agent(
state.clear_agent_session_caches(pubkey);
}
let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
let record = find_managed_agent_mut(&mut records, &pubkey)?;
let previous_record = record.clone();
let mut name_changed = false;
if let Some(name_update) = input.name {
let trimmed = name_update.trim().to_string();
@@ -929,14 +913,21 @@ pub async fn update_managed_agent(
record.respond_to_allowlist = prospective_allowlist;
}
let backend_deploy = super::agent_backend_update::apply_backend_update(
&app,
record,
&mut runtimes,
input.backend,
)?;
record.updated_at = now_iso();
save_managed_agents(&app, &records)?;
let record = records
.iter()
.find(|r| r.pubkey == input.pubkey)
.ok_or_else(|| format!("agent {} not found", input.pubkey))?;
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| format!("agent {} not found", pubkey))?;
// Publish the edit to the relay. After-save, inside the lock, before
// any .await. The retention upsert hashes the opt-IN projection, so an
@@ -979,11 +970,20 @@ pub async fn update_managed_agent(
)?
};
let rollback = name_changed.then(|| AgentUpdateRollback::new(previous_record, record));
(summary, sync_params, rollback)
(summary, sync_params, rollback, backend_deploy)
}; // lock dropped here
try_regenerate_nest(&app);
let summary = super::agent_backend_update::deploy_updated_backend(
&app,
&state,
&pubkey,
provider_deploy,
summary,
)
.await?;
// Phase 2: relay profile sync (async, outside lock). A rename is committed
// only when this succeeds; otherwise restore the complete pre-edit record
// so Desktop and the relay keep one authoritative name.
@@ -1,4 +1,5 @@
use super::*;
use crate::{commands::agent_backend_update, managed_agents::BackendKind};
#[test]
fn openai_model_normalization_keeps_agent_text_models() {
@@ -881,3 +882,47 @@ fn draft_agent_model_discovery_env_layers_all_three_tiers_in_order() {
);
}
}
#[test]
fn absent_backend_update_preserves_current_backend() {
let current = BackendKind::Provider {
id: "test".to_string(),
config: serde_json::json!({"name": "existing"}),
};
assert_eq!(
agent_backend_update::requested_backend_update(&current, None, None).unwrap(),
None
);
}
#[test]
fn provider_backend_update_requests_deploy() {
let requested = BackendKind::Provider {
id: "test".to_string(),
config: serde_json::json!({"name": "changed"}),
};
assert_eq!(
agent_backend_update::requested_backend_update(
&BackendKind::Local,
None,
Some(requested.clone())
)
.unwrap(),
Some(requested)
);
}
#[test]
fn deployed_provider_cannot_move_local_without_undeploy_protocol() {
let current = BackendKind::Provider {
id: "test".to_string(),
config: serde_json::json!({}),
};
let error = agent_backend_update::requested_backend_update(
&current,
Some("remote-1"),
Some(BackendKind::Local),
)
.unwrap_err();
assert!(error.contains("does not support undeploy"));
}
+2 -2
View File
@@ -456,7 +456,7 @@ pub(super) async fn start_local_agent_with_preflight(
///
/// Returns Ok(()) on success, Err(message) on failure. Either way the record is
/// updated and saved before returning.
async fn deploy_to_provider(
pub(super) async fn deploy_to_provider(
app: &AppHandle,
state: &AppState,
pubkey: &str,
@@ -1358,7 +1358,7 @@ pub async fn delete_managed_agent(
#[path = "agents_deploy.rs"]
mod deploy;
use deploy::build_deploy_payload;
pub(super) use deploy::build_deploy_payload;
#[cfg(test)]
use deploy::deploy_payload_json;
#[cfg(test)]
@@ -51,7 +51,7 @@ pub(crate) fn resolve_deploy_model_provider(
/// serialize `"private_key_nsec": ""` and launch the agent with no
/// identity — the same hazard the local spawn path refuses via
/// `spawn_key_refusal`.
pub(super) fn build_deploy_payload(
pub(crate) fn build_deploy_payload(
app: &AppHandle,
state: &AppState,
record: &ManagedAgentRecord,
+1
View File
@@ -1,4 +1,5 @@
mod agent_auth;
mod agent_backend_update;
mod agent_config;
mod agent_discovery;
mod agent_logs;
@@ -10,7 +10,11 @@ use uuid::Uuid;
use crate::{
app_state::AppState,
commands::{export_util::save_bytes_with_dialog, personas::resolve_snapshot_import_behavior},
commands::{
agents::{build_deploy_payload, deploy_to_provider},
export_util::save_bytes_with_dialog,
personas::resolve_snapshot_import_behavior,
},
managed_agents::team_snapshot::{
build_team_snapshot, decode_team_snapshot_json, decode_team_snapshot_png,
encode_team_snapshot_json, encode_team_snapshot_png, TeamSnapshot,
@@ -18,7 +22,7 @@ use crate::{
managed_agents::{
agent_snapshot::{build_snapshot, AgentSnapshot, AgentSnapshotMemoryEntry, MemoryLevel},
load_managed_agents, load_personas, load_teams, load_teams_readonly, save_managed_agents,
save_personas, save_teams, AgentDefinition, ManagedAgentRecord, TeamRecord,
save_personas, save_teams, AgentDefinition, BackendKind, ManagedAgentRecord, TeamRecord,
},
relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
@@ -220,6 +224,9 @@ pub struct TeamSnapshotImportConfirm {
pub file_bytes: Vec<u8>,
/// Applied uniformly to every member in v1.
pub keep_allowlist: bool,
/// Applied uniformly to every imported member. Absent preserves local.
#[serde(default)]
pub backend: Option<BackendKind>,
}
/// Per-member outcome reported after a confirmed team snapshot import.
@@ -252,6 +259,10 @@ pub struct EncodedTeamSnapshotPayload {
pub file_name: String,
}
fn import_backend_or_local(backend: Option<BackendKind>) -> BackendKind {
backend.unwrap_or_default()
}
/// Per-member minted key material, assembled before entering the store lock.
struct MintedMember {
definition: AgentDefinition,
@@ -504,6 +515,11 @@ pub async fn confirm_team_snapshot_import(
) -> Result<TeamSnapshotImportResult, String> {
// ── Phase 1: validate (no I/O) ───────────────────────────────────────────
let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?;
let import_backend = import_backend_or_local(input.backend);
if let BackendKind::Provider { config, id } = &import_backend {
crate::managed_agents::validate_provider_config(config)?;
crate::managed_agents::resolve_provider_binary(id)?;
}
let now = now_iso();
// Resolve behavioral defaults for every member before any key generation.
@@ -577,9 +593,15 @@ pub async fn confirm_team_snapshot_import(
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: crate::managed_agents::BackendKind::Local,
backend: import_backend.clone(),
backend_agent_id: None,
provider_binary_path: None,
provider_binary_path: if let BackendKind::Provider { ref id, .. } = import_backend {
crate::managed_agents::resolve_provider_binary(id)
.ok()
.map(|path| path.display().to_string())
} else {
None
},
team_id: Some(imported_team.id.clone()),
persona_team_dir: None,
persona_name_in_team: None,
@@ -754,7 +776,7 @@ pub async fn confirm_team_snapshot_import(
imported_team
};
// ── Phase 4 & 5: profile sync + memory restore (async, outside lock) ────
// ── Phase 4-6: profile sync, provider deploy, memory restore ──────────
let relay_ws = relay_ws_url_with_override(&state);
let mut member_results: Vec<TeamSnapshotImportMemberResult> = Vec::with_capacity(minted.len());
@@ -773,7 +795,24 @@ pub async fn confirm_team_snapshot_import(
.await
.err();
// Phase 5: memory restore (best-effort).
// Phase 5: provider deploy for remote imports (best-effort).
if let BackendKind::Provider { id, config } = &m.record.backend {
let agent_json = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(&app)?;
let record = records
.iter()
.find(|record| record.pubkey == m.pubkey)
.ok_or_else(|| format!("agent {} not found", m.pubkey))?;
build_deploy_payload(&app, &state, record)?
};
let _ = deploy_to_provider(&app, &state, &m.pubkey, id, config, agent_json, None).await;
}
// Phase 6: memory restore (best-effort).
let memory_total = snap_member.memory.entries.len();
let mut memory_written = 0usize;
let mut memory_errors: Vec<String> = Vec::new();
@@ -761,3 +761,17 @@ mod egress_guard_boundary {
assert!(err.contains("key-backup material"), "{err}");
}
}
#[test]
fn explicit_import_backend_is_applied() {
let backend = BackendKind::Provider {
id: "test".to_string(),
config: serde_json::json!({"name": "imported"}),
};
assert_eq!(import_backend_or_local(Some(backend.clone())), backend);
}
#[test]
fn absent_import_backend_defaults_local() {
assert_eq!(import_backend_or_local(None), BackendKind::Local);
}
@@ -200,6 +200,9 @@ pub struct CreateManagedAgentRequest {
#[serde(rename_all = "camelCase")]
pub struct UpdateManagedAgentRequest {
pub pubkey: String,
/// Absent = don't touch. Present = switch the execution backend.
#[serde(default)]
pub backend: Option<BackendKind>,
/// Absent = don't touch. Present = rename the agent.
#[serde(default)]
pub name: Option<String>,
@@ -2,7 +2,6 @@ import * as React from "react";
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import {
useAcpRuntimesQuery,
useAgentConfigSurface,
@@ -89,12 +88,13 @@ import {
runtimeDropdownAction,
usePendingHarnessSelection,
} from "./addCustomHarness";
import { WhereToRunSection } from "./WhereToRunSection";
import { canSubmitWhereToRun } from "./whereToRunIntent";
import { useAgentBackendEdit } from "./useAgentBackendEdit";
const ADVANCED_FIELDS_MOTION_TRANSITION = {
duration: 0.18,
ease: [0.23, 1, 0.32, 1],
} as const;
export function AgentInstanceEditDialog({
agent,
initialFocus,
@@ -117,7 +117,6 @@ export function AgentInstanceEditDialog({
const runtimesQuery = useAcpRuntimesQuery({ enabled: open });
const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null);
const runtimes = runtimesQuery.data ?? [];
const [name, setName] = React.useState(agent.name);
const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false);
const aiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
@@ -144,6 +143,7 @@ export function AgentInstanceEditDialog({
const [envVars, setEnvVars] = React.useState<EnvVarsValue>(agent.envVars);
const [autoRestartOnConfigChange, setAutoRestartOnConfigChange] =
React.useState(agent.autoRestartOnConfigChange);
const backendEdit = useAgentBackendEdit(agent.backend);
const personasQuery = usePersonasQuery();
const linkedPersona = React.useMemo(
() =>
@@ -165,14 +165,11 @@ export function AgentInstanceEditDialog({
React.useState(false);
const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false);
const shouldReduceMotion = useReducedMotion();
// Runtime selector: defaults to "custom" until the dialog opens and the
// catalog loads. The open-effect re-derives the correct id from the catalog.
const [selectedRuntimeId, setSelectedRuntimeId] = React.useState("custom");
// Tracks whether the user has made an in-dialog runtime selection.
const runtimeTouched = React.useRef(false);
// Reset form state only when the dialog opens or when switching to a different agent.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — including agent fields would re-fire on every 5s poll and wipe edits
React.useEffect(() => {
@@ -193,6 +190,7 @@ export function AgentInstanceEditDialog({
setIsCustomProviderEditing(false);
setEnvVars(agent.envVars);
setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange);
backendEdit.reset();
setRespondTo(agent.respondTo);
setRespondToAllowlist(agent.respondToAllowlist);
setAvatarUrl(agent.avatarUrl ?? "");
@@ -207,7 +205,6 @@ export function AgentInstanceEditDialog({
updateMutation.reset();
}
}, [open, agent.pubkey]);
// Re-derive the runtime id when the catalog loads.
React.useEffect(() => {
if (!open || runtimeTouched.current || runtimes.length === 0) {
@@ -220,7 +217,6 @@ export function AgentInstanceEditDialog({
setSelectedRuntimeId(matched.id);
}
}, [open, runtimes, agent.agentCommand]);
// Build the sorted runtime catalog for the dropdown.
const sortedRuntimes = React.useMemo(
() => sortPersonaRuntimes(runtimes),
@@ -231,7 +227,6 @@ export function AgentInstanceEditDialog({
() => runtimes.find((r) => r.id === selectedRuntimeId),
[runtimes, selectedRuntimeId],
);
const runtimeDropdownValue = selectedRuntimeId || NO_RUNTIME_DROPDOWN_VALUE;
const runtimeDropdownOptions: PersonaDropdownOption[] = React.useMemo(() => {
@@ -612,7 +607,8 @@ export function AgentInstanceEditDialog({
}) &&
providerValid &&
!updateMutation.isPending &&
!isAvatarUploadPending;
!isAvatarUploadPending &&
canSubmitWhereToRun(backendEdit.draft);
async function handleSubmit() {
try {
@@ -625,7 +621,6 @@ export function AgentInstanceEditDialog({
// provider-backed inherit-transition carries the persona model (readiness
// requires one) and a deliberate local model still wins.
const normalizedModel = inheritedSubmission.model;
// Harness pin resolution — see resolveAgentCommandUpdate for the full
// sentinel/pin/no-op contract, including the inherit→pin transition where
// the prefilled command equals the original but must still be pinned.
@@ -635,7 +630,6 @@ export function AgentInstanceEditDialog({
originalAgentCommand: agent.agentCommand,
agentCommandOverride: agent.agentCommandOverride ?? null,
});
// Classify the effective post-submit runtime's provider capability as a
// tri-state: "capable" persists the provider, "locked" clears it (only
// when we KNOW it's provider-locked, e.g. Claude), "unknown" OMITS it so a
@@ -648,7 +642,6 @@ export function AgentInstanceEditDialog({
prospectiveRuntimeId,
runtimeSupportsLlmProviderSelection(prospectiveRuntimeId),
);
// Provider + env to persist — the shared inherited-submission snapshot
// (same values the credential gate validates), so gate ↔ record ↔ spawn
// all agree. See resolveInheritedRuntimeSubmission.
@@ -723,6 +716,7 @@ export function AgentInstanceEditDialog({
respondToAllowlist.join(",") !== agent.respondToAllowlist.join(",")
? respondToAllowlist
: undefined,
backend: backendEdit.update,
};
const result = await updateMutation.mutateAsync(input);
@@ -946,6 +940,12 @@ export function AgentInstanceEditDialog({
variant="persona"
/>
<WhereToRunSection
draft={backendEdit.draft}
isPending={updateMutation.isPending}
onDraftChange={backendEdit.setDraft}
/>
{/* Provider (runtime) */}
<div className="space-y-1.5">
<label
@@ -621,8 +621,11 @@ export function AgentsView() {
isConfirming={teamActions.isTeamSnapshotImportConfirming}
result={teamActions.teamSnapshotImportResult}
confirmError={teamActions.teamSnapshotImportConfirmError}
onConfirm={(keepAllowlist) => {
void teamActions.handleConfirmTeamSnapshotImport(keepAllowlist);
onConfirm={(keepAllowlist, backend) => {
void teamActions.handleConfirmTeamSnapshotImport(
keepAllowlist,
backend,
);
}}
onOpenChange={(open) => {
if (!open) {
@@ -5,6 +5,7 @@ import type {
TeamSnapshotImportPreview,
TeamSnapshotImportResult,
} from "@/shared/api/tauriTeams";
import type { ManagedAgentBackend } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -18,6 +19,14 @@ import {
deriveImportPhase,
getProfileSyncFailures,
} from "./teamSnapshotImport.lib";
import { WhereToRunSection } from "./WhereToRunSection";
import {
backendIntentToManagedAgentBackend,
canSubmitWhereToRun,
emptyWhereToRunDraft,
resolveBackendIntent,
type WhereToRunDraft,
} from "./whereToRunIntent";
type TeamSnapshotImportDialogProps = {
open: boolean;
@@ -29,8 +38,8 @@ type TeamSnapshotImportDialogProps = {
result: TeamSnapshotImportResult | null;
/** Error from the confirm mutation, if any. */
confirmError: string | null;
/** Called with keepAllowlist when user clicks Import. */
onConfirm: (keepAllowlist: boolean) => void;
/** Called with keepAllowlist and backend when user clicks Import. */
onConfirm: (keepAllowlist: boolean, backend: ManagedAgentBackend) => void;
onOpenChange: (open: boolean) => void;
};
@@ -46,11 +55,13 @@ export function TeamSnapshotImportDialog({
onOpenChange,
}: TeamSnapshotImportDialogProps) {
const [keepAllowlist, setKeepAllowlist] = React.useState(false);
const [runDraft, setRunDraft] = React.useState(emptyWhereToRunDraft);
// Reset choice whenever the dialog opens with new data.
// Reset choices whenever the dialog opens with new data.
React.useEffect(() => {
if (open) {
setKeepAllowlist(false);
setRunDraft(emptyWhereToRunDraft);
}
}, [open]);
@@ -74,8 +85,15 @@ export function TeamSnapshotImportDialog({
<>
<Button
data-testid="team-snapshot-import-confirm"
disabled={isConfirming}
onClick={() => onConfirm(keepAllowlist)}
disabled={isConfirming || !canSubmitWhereToRun(runDraft)}
onClick={() =>
onConfirm(
keepAllowlist,
backendIntentToManagedAgentBackend(
resolveBackendIntent(runDraft),
),
)
}
size="sm"
type="button"
variant="default"
@@ -113,6 +131,9 @@ export function TeamSnapshotImportDialog({
preview={preview}
keepAllowlist={keepAllowlist}
onKeepAllowlistChange={setKeepAllowlist}
runDraft={runDraft}
onRunDraftChange={setRunDraft}
isConfirming={isConfirming}
/>
{confirmError ? (
<div
@@ -142,10 +163,16 @@ function PreviewBody({
preview,
keepAllowlist,
onKeepAllowlistChange,
runDraft,
onRunDraftChange,
isConfirming,
}: {
preview: TeamSnapshotImportPreview;
keepAllowlist: boolean;
onKeepAllowlistChange: (v: boolean) => void;
runDraft: WhereToRunDraft;
onRunDraftChange: (draft: WhereToRunDraft) => void;
isConfirming: boolean;
}) {
return (
<div className="space-y-4 py-1">
@@ -189,6 +216,12 @@ function PreviewBody({
</div>
) : null}
<WhereToRunSection
draft={runDraft}
isPending={isConfirming}
onDraftChange={onRunDraftChange}
/>
{/* Allowlist section */}
{preview.hasSourceAllowlist ? (
<div
@@ -50,7 +50,8 @@ export function WhereToRunSection({
onDraftChange({
...draft,
probedProvider: result,
providerConfig: defaults,
providerConfig: { ...defaults, ...draft.providerConfig },
allowUnprobedProvider: false,
});
})
.catch((error: unknown) => {
@@ -0,0 +1,52 @@
import * as React from "react";
import type { ManagedAgentBackend } from "@/shared/api/types";
import {
backendIntentToManagedAgentBackend,
resolveBackendIntent,
type WhereToRunDraft,
} from "./whereToRunIntent";
function draftForBackend(backend: ManagedAgentBackend): WhereToRunDraft {
if (backend.type === "local") {
return {
runOn: "local" as const,
providerConfig: {},
probedProvider: null,
};
}
return {
runOn: backend.id,
providerConfig: Object.fromEntries(
Object.entries(backend.config).map(([key, value]) => [
key,
String(value),
]),
),
probedProvider: null,
allowUnprobedProvider: true,
};
}
export function useAgentBackendEdit(backend: ManagedAgentBackend) {
const [draft, setDraft] = React.useState<WhereToRunDraft>(() =>
draftForBackend(backend),
);
const reset = React.useCallback(
() => setDraft(draftForBackend(backend)),
[backend],
);
const selected = backendIntentToManagedAgentBackend(
resolveBackendIntent(draft),
);
return {
draft,
setDraft,
reset,
update:
JSON.stringify(selected) === JSON.stringify(backend)
? undefined
: selected,
};
}
@@ -25,6 +25,7 @@ import type {
AgentTeam,
Channel,
CreateTeamInput,
ManagedAgentBackend,
UpdateTeamInput,
} from "@/shared/api/types";
import { deriveImportToast } from "./teamSnapshotImport.lib";
@@ -98,8 +99,7 @@ export function useTeamActions(
}) => previewTeamSnapshotImport(fileBytes, fileName),
});
const confirmTeamSnapshotImportMutation = useMutation({
mutationFn: (input: { fileBytes: number[]; keepAllowlist: boolean }) =>
confirmTeamSnapshotImport(input),
mutationFn: confirmTeamSnapshotImport,
});
const teams = teamsQuery.data ?? [];
@@ -281,7 +281,10 @@ export function useTeamActions(
}
}
async function handleConfirmTeamSnapshotImport(keepAllowlist: boolean) {
async function handleConfirmTeamSnapshotImport(
keepAllowlist: boolean,
backend: ManagedAgentBackend,
) {
if (!teamSnapshotImportState) {
return;
}
@@ -290,6 +293,7 @@ export function useTeamActions(
const result = await confirmTeamSnapshotImportMutation.mutateAsync({
fileBytes: teamSnapshotImportState.fileBytes,
keepAllowlist,
backend,
});
setTeamSnapshotImportResult(result);
void queryClient.invalidateQueries({ queryKey: personasQueryKey });
@@ -1,5 +1,8 @@
import type { BackendIntent } from "../lib/instanceInputForDefinition";
import type { BackendProviderProbeResult } from "@/shared/api/types";
import type {
BackendProviderProbeResult,
ManagedAgentBackend,
} from "@/shared/api/types";
import { coerceConfigValues } from "./ProviderConfigFields";
/** Draft state of the optional remote-backend selector. */
@@ -7,6 +10,8 @@ export type WhereToRunDraft = {
runOn: "local" | string;
providerConfig: Record<string, string>;
probedProvider: BackendProviderProbeResult | null;
/** Existing persisted provider selections remain valid while their probe loads. */
allowUnprobedProvider?: boolean;
};
export const emptyWhereToRunDraft: WhereToRunDraft = {
@@ -17,7 +22,7 @@ export const emptyWhereToRunDraft: WhereToRunDraft = {
export function providerConfigComplete(draft: WhereToRunDraft): boolean {
if (draft.runOn === "local") return true;
if (!draft.probedProvider) return false;
if (!draft.probedProvider) return draft.allowUnprobedProvider === true;
const schema = draft.probedProvider.config_schema as
| Record<string, unknown>
| undefined;
@@ -44,3 +49,15 @@ export function resolveBackendIntent(
),
};
}
export function backendIntentToManagedAgentBackend(
backendIntent: BackendIntent | null,
): ManagedAgentBackend {
return backendIntent
? {
type: "provider" as const,
id: backendIntent.id,
config: backendIntent.config,
}
: { type: "local" as const };
}
+2
View File
@@ -2,6 +2,7 @@ import { invokeTauri } from "@/shared/api/tauri";
import type {
AgentTeam,
CreateTeamInput,
ManagedAgentBackend,
UpdateTeamInput,
} from "@/shared/api/types";
@@ -101,6 +102,7 @@ export type TeamSnapshotImportPreview = {
export type TeamSnapshotImportConfirm = {
fileBytes: number[];
keepAllowlist: boolean;
backend?: ManagedAgentBackend;
};
export type TeamSnapshotImportMemberResult = {
+2 -2
View File
@@ -1,7 +1,6 @@
export type ChannelType = "stream" | "forum" | "dm";
export type ChannelVisibility = "open" | "private";
export type ChannelRole = "owner" | "admin" | "member" | "guest" | "bot";
export type Channel = {
id: string;
name: string;
@@ -20,7 +19,6 @@ export type Channel = {
ttlSeconds: number | null;
ttlDeadline: string | null;
};
export type ChannelDetail = Channel & {
createdBy: string;
createdAt: string;
@@ -711,6 +709,8 @@ export type RuntimeConfigSurface = {
export type UpdateManagedAgentInput = {
pubkey: string;
/** Absent = don't touch. Present = switch the execution backend. */
backend?: ManagedAgentBackend;
name?: string;
model?: string | null;
provider?: string | null;