mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): harden relay mesh connect p-tag (#834)
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
use nostr::{Keys, ToBech32};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
use crate::managed_agents::relay_mesh_model_id;
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
@@ -33,18 +31,16 @@ fn workspace_owner_hex(state: &AppState) -> Result<String, String> {
|
||||
async fn ensure_relay_mesh_for_record(
|
||||
state: &AppState,
|
||||
record: &ManagedAgentRecord,
|
||||
allow_fresh_create_start: bool,
|
||||
) -> Result<(), String> {
|
||||
let Some(model_id) = relay_mesh_model_id(record) else {
|
||||
return Ok(());
|
||||
};
|
||||
crate::commands::mesh_llm::ensure_client_node_for_model(state, model_id, None).await?;
|
||||
Ok(())
|
||||
crate::commands::ensure_relay_mesh_for_record(state, record, allow_fresh_create_start).await
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mesh-llm"))]
|
||||
async fn ensure_relay_mesh_for_record(
|
||||
_state: &AppState,
|
||||
_record: &ManagedAgentRecord,
|
||||
_allow_fresh_create_start: bool,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -54,6 +50,7 @@ async fn start_local_agent_with_preflight(
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
owner_hex: &str,
|
||||
allow_fresh_create_start: bool,
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
let record_snapshot = {
|
||||
let _store_guard = state
|
||||
@@ -72,7 +69,7 @@ async fn start_local_agent_with_preflight(
|
||||
return Err(format!("agent {pubkey} is not a local agent"));
|
||||
}
|
||||
|
||||
ensure_relay_mesh_for_record(state, &record_snapshot).await?;
|
||||
ensure_relay_mesh_for_record(state, &record_snapshot, allow_fresh_create_start).await?;
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
@@ -519,7 +516,7 @@ pub async fn create_managed_agent(
|
||||
// ── Phase 3b: local spawn (async preflight outside store lock) ───────────
|
||||
let mut spawn_error = None;
|
||||
let agent = if input.spawn_after_create && input.backend == BackendKind::Local {
|
||||
match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex).await {
|
||||
match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, true).await {
|
||||
Ok(agent) => agent,
|
||||
Err(error) => {
|
||||
let _store_guard = state
|
||||
@@ -693,7 +690,7 @@ pub async fn start_managed_agent(
|
||||
|
||||
match target {
|
||||
StartTarget::Local => {
|
||||
start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex).await
|
||||
start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, false).await
|
||||
}
|
||||
StartTarget::Provider {
|
||||
backend: BackendKind::Provider { id, config },
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{app_state::AppState, mesh_llm, relay};
|
||||
use crate::{app_state::AppState, managed_agents::RELAY_MESH_API_BASE_URL, mesh_llm, relay};
|
||||
|
||||
const RELAY_MESH_RUNTIME_NO_TARGET: &str =
|
||||
"relay mesh client start requires a concrete serve target; reopen the agent with Run on relay mesh selected to refresh its target";
|
||||
|
||||
pub type CmdResult<T> = Result<T, String>;
|
||||
|
||||
@@ -93,27 +96,7 @@ pub(crate) async fn ensure_client_node_for_model(
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some(value) => value,
|
||||
None => {
|
||||
let availability =
|
||||
match relay::query_relay(state, &[mesh_llm::mesh_status_filter()]).await {
|
||||
Ok(events) => mesh_llm::availability_from_events(events),
|
||||
Err(error) => return Err(format!("failed to read relay mesh status: {error}")),
|
||||
};
|
||||
if !availability.available {
|
||||
return Err(availability
|
||||
.reason
|
||||
.unwrap_or_else(|| "relay mesh is not available".to_string()));
|
||||
}
|
||||
let target = availability
|
||||
.serve_targets
|
||||
.iter()
|
||||
.find(|target| target.model_id == requested_model)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
format!("relay mesh has no serve target for model {requested_model}")
|
||||
})?;
|
||||
target.endpoint_addr
|
||||
}
|
||||
None => return Err(RELAY_MESH_RUNTIME_NO_TARGET.to_string()),
|
||||
};
|
||||
|
||||
let start = mesh_llm::StartMeshNodeRequest {
|
||||
@@ -137,6 +120,85 @@ pub(crate) async fn ensure_client_node_for_model(
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Re-resolve a live serve target's dial pointer for a saved relay-mesh agent.
|
||||
///
|
||||
/// The serve target's `endpoint_addr` is live discovery state — it comes from
|
||||
/// the peer's replaceable kind:30621 status event and rotates when the peer's
|
||||
/// iroh endpoint changes — so it is never persisted onto the agent record.
|
||||
/// Instead, a saved agent re-resolves a current bootstrap target at start time
|
||||
/// by matching its configured model against the targets the relay is gossiping
|
||||
/// right now. We only need *any* live target for the model to bootstrap the
|
||||
/// client node; mesh-llm's router picks the per-request host afterwards.
|
||||
///
|
||||
/// `Err` means the relay query itself failed (relay down, auth, network) — we
|
||||
/// could not refresh targets at all and must not pretend the peer is offline.
|
||||
/// `Ok(None)` means the relay answered but no live target currently serves this
|
||||
/// model (genuine peer-offline). `Ok(Some(addr))` is a dialable bootstrap
|
||||
/// target.
|
||||
pub(crate) async fn resolve_mesh_bootstrap_target(
|
||||
state: &AppState,
|
||||
model_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let model_id = model_id.trim();
|
||||
if model_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let events = relay::query_relay(state, &[mesh_llm::mesh_status_filter()]).await?;
|
||||
Ok(pick_serve_target_for_model(
|
||||
mesh_llm::availability_from_events(events).serve_targets,
|
||||
model_id,
|
||||
))
|
||||
}
|
||||
|
||||
/// Pure target-selection used by `resolve_mesh_bootstrap_target`: the first
|
||||
/// gossiped serve target that hosts `model_id`. Split out so the matching rule
|
||||
/// is unit-testable without a relay round-trip.
|
||||
fn pick_serve_target_for_model(
|
||||
targets: Vec<mesh_llm::MeshServeTarget>,
|
||||
model_id: &str,
|
||||
) -> Option<String> {
|
||||
targets
|
||||
.into_iter()
|
||||
.find(|target| target.model_id == model_id)
|
||||
.map(|target| target.endpoint_addr)
|
||||
}
|
||||
|
||||
/// Decide whether a relay-mesh agent may start, and bring up its local mesh
|
||||
/// client when needed.
|
||||
///
|
||||
/// Fresh create (`allow_fresh_create_start`) has just run the client-start flow
|
||||
/// from the dialog, so it spawns as-is. For a saved/manual start the serve
|
||||
/// target's dial pointer was never persisted (it is live discovery state), so
|
||||
/// re-resolve a current bootstrap target from the relay's gossiped targets and
|
||||
/// dial it. The two failure modes get distinct, actionable copy: a relay query
|
||||
/// failure ("could not refresh targets") is not the same as a relay that
|
||||
/// answered with no live target for this model ("peer offline"). Non relay-mesh
|
||||
/// records are a no-op.
|
||||
pub(crate) async fn ensure_relay_mesh_for_record(
|
||||
state: &AppState,
|
||||
record: &crate::managed_agents::ManagedAgentRecord,
|
||||
allow_fresh_create_start: bool,
|
||||
) -> Result<(), String> {
|
||||
if allow_fresh_create_start {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(model_id) = crate::managed_agents::relay_mesh_model_id(record) else {
|
||||
return Ok(());
|
||||
};
|
||||
match resolve_mesh_bootstrap_target(state, &model_id).await {
|
||||
Ok(Some(endpoint_addr)) => {
|
||||
ensure_client_node_for_model(state, &model_id, Some(endpoint_addr)).await?;
|
||||
Ok(())
|
||||
}
|
||||
Ok(None) => Err(format!(
|
||||
"relay mesh agents cannot be started from saved state because no live serve target is available for this model. Start serving on a mesh peer, or create a new agent with Run on relay mesh selected to refresh the target for {RELAY_MESH_API_BASE_URL}."
|
||||
)),
|
||||
Err(error) => Err(format!(
|
||||
"could not refresh relay mesh serve targets to start this agent: {error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshDialEndpointRequest {
|
||||
@@ -222,6 +284,50 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::app_state::build_app_state;
|
||||
|
||||
fn target(model_id: &str, endpoint_addr: &str) -> mesh_llm::MeshServeTarget {
|
||||
mesh_llm::MeshServeTarget {
|
||||
model_id: model_id.to_string(),
|
||||
model_name: None,
|
||||
endpoint_addr: endpoint_addr.to_string(),
|
||||
node_name: None,
|
||||
capacity: None,
|
||||
reporter_pubkey: None,
|
||||
endpoint_id: None,
|
||||
device_id: None,
|
||||
device_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_serve_target_returns_first_match_for_model() {
|
||||
let targets = vec![
|
||||
target("model-a", "addr-a"),
|
||||
target("model-b", "addr-b1"),
|
||||
target("model-b", "addr-b2"),
|
||||
];
|
||||
// Matches by model id, returns the first such target's dial pointer.
|
||||
assert_eq!(
|
||||
pick_serve_target_for_model(targets, "model-b"),
|
||||
Some("addr-b1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_serve_target_none_when_model_not_hosted() {
|
||||
let targets = vec![target("model-a", "addr-a")];
|
||||
// No live target serves this model -> caller falls closed.
|
||||
assert_eq!(pick_serve_target_for_model(targets, "model-missing"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_client_preflight_requires_explicit_target() {
|
||||
let state = build_app_state();
|
||||
let error = ensure_client_node_for_model(&state, "demo/model", None)
|
||||
.await
|
||||
.expect_err("cold relay-mesh preflight must not auto-pick a target");
|
||||
assert_eq!(error, RELAY_MESH_RUNTIME_NO_TARGET);
|
||||
}
|
||||
|
||||
/// Acceptance-critical regression for dropping the serve-vs-client guard.
|
||||
///
|
||||
/// Before this change, `ensure_client_node_for_model` hard-errored whenever
|
||||
@@ -231,11 +337,10 @@ mod tests {
|
||||
/// through the same `9337` ingress.
|
||||
///
|
||||
/// This test starts a real serve runtime and asserts that a follow-up
|
||||
/// preflight for a *different* model:
|
||||
/// 1. does NOT reject on mode, and
|
||||
/// 2. returns the existing runtime's status (same `9337` ingress), so the
|
||||
/// agent keeps talking to the running node and mesh-llm's router
|
||||
/// resolves the model per request.
|
||||
/// preflight for a *different* model and no explicit target still reuses the
|
||||
/// existing runtime. Cold starts without a target are rejected before mesh-llm
|
||||
/// startup; running runtimes are already joined to whatever target the
|
||||
/// frontend selected earlier.
|
||||
///
|
||||
/// Hardware-gated (`#[ignore]`): loads a real model. Run with:
|
||||
/// cargo test -p sprout-desktop --features mesh-llm \
|
||||
|
||||
@@ -5,6 +5,8 @@ mod nest;
|
||||
mod persona_avatars;
|
||||
mod persona_card;
|
||||
mod personas;
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
mod relay_mesh;
|
||||
mod restore;
|
||||
mod runtime;
|
||||
mod storage;
|
||||
@@ -17,6 +19,8 @@ pub use env_vars::*;
|
||||
pub use nest::*;
|
||||
pub use persona_card::*;
|
||||
pub use personas::*;
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
pub use relay_mesh::*;
|
||||
pub use restore::*;
|
||||
pub use runtime::*;
|
||||
pub use storage::*;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
use super::ManagedAgentRecord;
|
||||
|
||||
pub const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1";
|
||||
pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "sprout-mesh-local";
|
||||
|
||||
/// Returns the relay-mesh model id for agents whose provider env points at the
|
||||
/// local mesh client endpoint created by Sprout's relay-mesh preset.
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
pub fn relay_mesh_model_id(record: &ManagedAgentRecord) -> Option<String> {
|
||||
let base_url = record.env_vars.get("OPENAI_COMPAT_BASE_URL")?.trim();
|
||||
if base_url.trim_end_matches('/') != RELAY_MESH_API_BASE_URL {
|
||||
return None;
|
||||
}
|
||||
let provider = record.env_vars.get("SPROUT_AGENT_PROVIDER")?.trim();
|
||||
if provider != "openai" {
|
||||
return None;
|
||||
}
|
||||
let api_key = record.env_vars.get("OPENAI_COMPAT_API_KEY")?.trim();
|
||||
if api_key != RELAY_MESH_API_KEY_PLACEHOLDER {
|
||||
return None;
|
||||
}
|
||||
record
|
||||
.env_vars
|
||||
.get("OPENAI_COMPAT_MODEL")
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
use crate::managed_agents::{BackendKind, RespondTo};
|
||||
|
||||
fn fixture() -> ManagedAgentRecord {
|
||||
ManagedAgentRecord {
|
||||
pubkey: "p".into(),
|
||||
name: "n".into(),
|
||||
persona_id: None,
|
||||
private_key_nsec: "nsec1fake".into(),
|
||||
auth_tag: Some("tag".into()),
|
||||
relay_url: "ws://localhost:3000".into(),
|
||||
acp_command: "sprout-acp".into(),
|
||||
agent_command: "goose".into(),
|
||||
agent_args: vec![],
|
||||
mcp_command: "sprout-mcp-server".into(),
|
||||
turn_timeout_seconds: 320,
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
parallelism: 1,
|
||||
system_prompt: None,
|
||||
model: None,
|
||||
mcp_toolsets: None,
|
||||
env_vars: BTreeMap::new(),
|
||||
start_on_app_launch: false,
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::Local,
|
||||
backend_agent_id: None,
|
||||
provider_binary_path: None,
|
||||
persona_pack_path: None,
|
||||
persona_name_in_pack: None,
|
||||
created_at: "now".into(),
|
||||
updated_at: "now".into(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
respond_to: RespondTo::OwnerOnly,
|
||||
respond_to_allowlist: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
#[test]
|
||||
fn relay_mesh_model_id_detects_mesh_preset_env() {
|
||||
let mut rec = fixture();
|
||||
rec.env_vars = BTreeMap::from([
|
||||
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_BASE_URL".to_string(),
|
||||
"http://127.0.0.1:9337/v1/".to_string(),
|
||||
),
|
||||
("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_API_KEY".to_string(),
|
||||
RELAY_MESH_API_KEY_PLACEHOLDER.to_string(),
|
||||
),
|
||||
]);
|
||||
|
||||
assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
#[test]
|
||||
fn relay_mesh_model_id_ignores_non_mesh_openai_env() {
|
||||
let mut rec = fixture();
|
||||
rec.env_vars = BTreeMap::from([
|
||||
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_BASE_URL".to_string(),
|
||||
"https://api.openai.com/v1".to_string(),
|
||||
),
|
||||
("OPENAI_COMPAT_MODEL".to_string(), "gpt-5".to_string()),
|
||||
]);
|
||||
|
||||
assert_eq!(relay_mesh_model_id(&rec), None);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
#[test]
|
||||
fn relay_mesh_model_id_ignores_user_openai_on_same_local_port() {
|
||||
let mut rec = fixture();
|
||||
rec.env_vars = BTreeMap::from([
|
||||
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_BASE_URL".to_string(),
|
||||
"http://127.0.0.1:9337/v1".to_string(),
|
||||
),
|
||||
("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()),
|
||||
("OPENAI_COMPAT_API_KEY".to_string(), "real-key".to_string()),
|
||||
]);
|
||||
|
||||
assert_eq!(relay_mesh_model_id(&rec), None);
|
||||
}
|
||||
}
|
||||
@@ -108,11 +108,14 @@ pub async fn restore_managed_agents_on_launch(
|
||||
let agents_to_start = {
|
||||
let mut mesh_preflight_failures = std::collections::HashSet::new();
|
||||
for record in &agents_to_start {
|
||||
let Some(model_id) = relay_mesh_model_id(record) else {
|
||||
if relay_mesh_model_id(record).is_none() {
|
||||
continue;
|
||||
};
|
||||
}
|
||||
// Auto-start after relaunch: re-resolve a live bootstrap target and
|
||||
// dial it. Skip (with an actionable error) only when no live target
|
||||
// serves this model right now.
|
||||
if let Err(error) =
|
||||
crate::commands::ensure_client_node_for_model(&state, model_id, None).await
|
||||
crate::commands::ensure_relay_mesh_for_record(&state, record, false).await
|
||||
{
|
||||
persist_restore_error(app, &state, &record.pubkey, error)?;
|
||||
mesh_preflight_failures.insert(record.pubkey.clone());
|
||||
|
||||
@@ -1043,27 +1043,6 @@ fn child_rust_log_filter() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the relay-mesh model id for agents whose provider env points at the
|
||||
/// local mesh client endpoint. The caller is responsible for ensuring that
|
||||
/// endpoint is alive before spawning the agent process.
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
pub fn relay_mesh_model_id(record: &ManagedAgentRecord) -> Option<String> {
|
||||
let base_url = record.env_vars.get("OPENAI_COMPAT_BASE_URL")?.trim();
|
||||
if base_url.trim_end_matches('/') != "http://127.0.0.1:9337/v1" {
|
||||
return None;
|
||||
}
|
||||
let provider = record.env_vars.get("SPROUT_AGENT_PROVIDER")?.trim();
|
||||
if provider != "openai" {
|
||||
return None;
|
||||
}
|
||||
record
|
||||
.env_vars
|
||||
.get("OPENAI_COMPAT_MODEL")
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn start_managed_agent_process(
|
||||
app: &AppHandle,
|
||||
record: &mut ManagedAgentRecord,
|
||||
@@ -1170,8 +1149,6 @@ pub fn stop_managed_agent_process(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
use super::relay_mesh_model_id;
|
||||
use crate::managed_agents::known_acp_provider;
|
||||
|
||||
#[test]
|
||||
@@ -1263,38 +1240,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
#[test]
|
||||
fn relay_mesh_model_id_detects_mesh_preset_env() {
|
||||
let mut rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
|
||||
rec.env_vars = std::collections::BTreeMap::from([
|
||||
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_BASE_URL".to_string(),
|
||||
"http://127.0.0.1:9337/v1/".to_string(),
|
||||
),
|
||||
("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()),
|
||||
]);
|
||||
|
||||
assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
#[test]
|
||||
fn relay_mesh_model_id_ignores_non_mesh_openai_env() {
|
||||
let mut rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
|
||||
rec.env_vars = std::collections::BTreeMap::from([
|
||||
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_BASE_URL".to_string(),
|
||||
"https://api.openai.com/v1".to_string(),
|
||||
),
|
||||
("OPENAI_COMPAT_MODEL".to_string(), "gpt-5".to_string()),
|
||||
]);
|
||||
|
||||
assert_eq!(relay_mesh_model_id(&rec), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_env_owner_only_sets_mode_and_removes_others() {
|
||||
let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
|
||||
|
||||
@@ -4,6 +4,9 @@ mod discovery;
|
||||
pub use discovery::{availability_from_events, mesh_status_filter};
|
||||
use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity};
|
||||
|
||||
mod preset;
|
||||
pub use preset::{agent_preset, MeshAgentPreset, MeshAgentPresetRequest};
|
||||
|
||||
use mesh_llm_sdk::{client, serve, EmbeddedNodeHandle, MeshDiscoveryMode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -13,6 +16,12 @@ const MESH_STATUS_KIND: u64 = 30_621;
|
||||
const MESH_API_PORT_ENV: &str = "SPROUT_MESH_API_PORT";
|
||||
const MESH_CONSOLE_PORT_ENV: &str = "SPROUT_MESH_CONSOLE_PORT";
|
||||
const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "sprout-mesh-local";
|
||||
/// ACP provider relay-mesh agents run on. Sources of truth for its command +
|
||||
/// MCP live in the provider catalog (`known_acp_provider_exact`); these are
|
||||
/// only the fallbacks. `sprout-agent` reads the `SPROUT_AGENT_PROVIDER` /
|
||||
/// `OPENAI_COMPAT_*` env vars below — goose (the global default) does not.
|
||||
const MESH_AGENT_PROVIDER_ID: &str = "sprout-agent";
|
||||
const MESH_AGENT_MCP_COMMAND: &str = "sprout-dev-mcp";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -442,54 +451,6 @@ pub(super) fn dedupe_models(models: Vec<MeshModelOption>) -> Vec<MeshModelOption
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshAgentPresetRequest {
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshAgentPreset {
|
||||
pub provider_id: String,
|
||||
pub label: String,
|
||||
pub acp_command: String,
|
||||
pub agent_command: String,
|
||||
pub agent_args: Vec<String>,
|
||||
pub mcp_command: String,
|
||||
pub model: String,
|
||||
pub env_vars: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn agent_preset(request: MeshAgentPresetRequest) -> Result<MeshAgentPreset, String> {
|
||||
let model = request.model_id.trim();
|
||||
if model.is_empty() {
|
||||
return Err("modelId is required".to_string());
|
||||
}
|
||||
Ok(MeshAgentPreset {
|
||||
provider_id: "relay-mesh".to_string(),
|
||||
label: "Relay mesh".to_string(),
|
||||
acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(),
|
||||
agent_command: crate::managed_agents::DEFAULT_AGENT_COMMAND.to_string(),
|
||||
agent_args: Vec::new(),
|
||||
mcp_command: crate::managed_agents::DEFAULT_MCP_COMMAND.to_string(),
|
||||
model: model.to_string(),
|
||||
env_vars: BTreeMap::from([
|
||||
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_BASE_URL".to_string(),
|
||||
relay_mesh_api_base_url()?,
|
||||
),
|
||||
("OPENAI_COMPAT_MODEL".to_string(), model.to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_API_KEY".to_string(),
|
||||
RELAY_MESH_API_KEY_PLACEHOLDER.to_string(),
|
||||
),
|
||||
("OPENAI_COMPAT_API".to_string(), "chat".to_string()),
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod mod_tests;
|
||||
|
||||
@@ -32,3 +32,35 @@ fn model_ref_is_family_agnostic() {
|
||||
assert!(!looks_like_model_ref("Qwen3-35B"));
|
||||
assert!(!looks_like_model_ref(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_preset_runs_on_sprout_agent_not_goose() {
|
||||
// Regression (Tyler): the relay-mesh preset used to hand the agent the
|
||||
// global default runtime (goose), which ignores the OpenAI-compat env
|
||||
// vars and falls back to its own provider. Mesh agents must run on
|
||||
// sprout-agent, which reads those vars.
|
||||
let preset = super::agent_preset(super::MeshAgentPresetRequest {
|
||||
model_id: "Qwen3-8B-Q4_K_M".to_string(),
|
||||
})
|
||||
.expect("preset for a valid model id");
|
||||
|
||||
assert_eq!(preset.agent_command, "sprout-agent");
|
||||
assert_ne!(preset.agent_command, "goose");
|
||||
assert_eq!(preset.mcp_command, "sprout-dev-mcp");
|
||||
|
||||
// The env vars sprout-agent's config layer reads (crates/sprout-agent).
|
||||
assert_eq!(
|
||||
preset
|
||||
.env_vars
|
||||
.get("SPROUT_AGENT_PROVIDER")
|
||||
.map(String::as_str),
|
||||
Some("openai")
|
||||
);
|
||||
assert_eq!(
|
||||
preset
|
||||
.env_vars
|
||||
.get("OPENAI_COMPAT_MODEL")
|
||||
.map(String::as_str),
|
||||
Some("Qwen3-8B-Q4_K_M")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Relay-mesh "Run on relay mesh" agent preset. Kept in a sibling file so
|
||||
//! `mod.rs` stays under the 500-line budget; `#[path]`-included from there.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
relay_mesh_api_base_url, MESH_AGENT_MCP_COMMAND, MESH_AGENT_PROVIDER_ID,
|
||||
RELAY_MESH_API_KEY_PLACEHOLDER,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshAgentPresetRequest {
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshAgentPreset {
|
||||
pub provider_id: String,
|
||||
pub label: String,
|
||||
pub acp_command: String,
|
||||
pub agent_command: String,
|
||||
pub agent_args: Vec<String>,
|
||||
pub mcp_command: String,
|
||||
pub model: String,
|
||||
pub env_vars: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn agent_preset(request: MeshAgentPresetRequest) -> Result<MeshAgentPreset, String> {
|
||||
let model = request.model_id.trim();
|
||||
if model.is_empty() {
|
||||
return Err("modelId is required".to_string());
|
||||
}
|
||||
// Run on sprout-agent, not the global default (goose). Source command +
|
||||
// MCP from the catalog so this can't drift from the provider definition.
|
||||
let sprout_agent = crate::managed_agents::known_acp_provider_exact(MESH_AGENT_PROVIDER_ID);
|
||||
let agent_command = sprout_agent
|
||||
.and_then(|p| p.commands.first().copied())
|
||||
.unwrap_or(MESH_AGENT_PROVIDER_ID)
|
||||
.to_string();
|
||||
let mcp_command = sprout_agent
|
||||
.and_then(|p| p.mcp_command)
|
||||
.unwrap_or(MESH_AGENT_MCP_COMMAND)
|
||||
.to_string();
|
||||
Ok(MeshAgentPreset {
|
||||
provider_id: "relay-mesh".to_string(),
|
||||
label: "Relay mesh".to_string(),
|
||||
acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(),
|
||||
agent_command,
|
||||
agent_args: Vec::new(),
|
||||
mcp_command,
|
||||
model: model.to_string(),
|
||||
env_vars: BTreeMap::from([
|
||||
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_BASE_URL".to_string(),
|
||||
relay_mesh_api_base_url()?,
|
||||
),
|
||||
("OPENAI_COMPAT_MODEL".to_string(), model.to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_API_KEY".to_string(),
|
||||
RELAY_MESH_API_KEY_PLACEHOLDER.to_string(),
|
||||
),
|
||||
("OPENAI_COMPAT_API".to_string(), "chat".to_string()),
|
||||
]),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { startManagedAgentWithRules } from "./managedAgentControlActions.ts";
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
name: "Mesh Agent",
|
||||
personaId: null,
|
||||
relayUrl: "ws://localhost:3000",
|
||||
acpCommand: "sprout-acp",
|
||||
agentCommand: "goose",
|
||||
agentArgs: [],
|
||||
mcpCommand: "sprout-mcp-server",
|
||||
turnTimeoutSeconds: 320,
|
||||
idleTimeoutSeconds: null,
|
||||
maxTurnDurationSeconds: null,
|
||||
parallelism: 1,
|
||||
systemPrompt: null,
|
||||
model: "hf://demo/model.gguf",
|
||||
mcpToolsets: null,
|
||||
envVars: {},
|
||||
status: "stopped",
|
||||
pid: null,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
lastStartedAt: null,
|
||||
lastStoppedAt: null,
|
||||
lastExitCode: null,
|
||||
lastError: null,
|
||||
logPath: null,
|
||||
startOnAppLaunch: false,
|
||||
backend: { type: "local" },
|
||||
backendAgentId: null,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("relay-mesh agents cannot be manually started without a fresh target", async () => {
|
||||
let called = false;
|
||||
await assert.rejects(
|
||||
startManagedAgentWithRules({
|
||||
agent: agent({
|
||||
envVars: {
|
||||
SPROUT_AGENT_PROVIDER: "openai",
|
||||
OPENAI_COMPAT_BASE_URL: "http://127.0.0.1:9337/v1/",
|
||||
},
|
||||
}),
|
||||
startManagedAgent: async () => {
|
||||
called = true;
|
||||
},
|
||||
}),
|
||||
/Relay-mesh agents need a fresh serve target/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("ordinary local agents still start normally", async () => {
|
||||
let calledWith = null;
|
||||
await startManagedAgentWithRules({
|
||||
agent: agent(),
|
||||
startManagedAgent: async (pubkey) => {
|
||||
calledWith = pubkey;
|
||||
},
|
||||
});
|
||||
assert.equal(calledWith, "deadbeef".repeat(8));
|
||||
});
|
||||
@@ -75,6 +75,18 @@ export function resolveManagedAgentChannelId(
|
||||
return matches.length === 1 ? matches[0].id : null;
|
||||
}
|
||||
|
||||
function relayMeshAgentError(agent: ManagedAgent): string | null {
|
||||
if (agent.backend.type !== "local") return null;
|
||||
if (agent.envVars.SPROUT_AGENT_PROVIDER !== "openai") return null;
|
||||
if (
|
||||
agent.envVars.OPENAI_COMPAT_BASE_URL?.replace(/\/+$/, "") !==
|
||||
"http://127.0.0.1:9337/v1"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return "Relay-mesh agents need a fresh serve target before start. Create a new agent with Run on relay mesh selected.";
|
||||
}
|
||||
|
||||
export async function startManagedAgentWithRules({
|
||||
agent,
|
||||
startManagedAgent,
|
||||
@@ -82,6 +94,8 @@ export async function startManagedAgentWithRules({
|
||||
agent: ManagedAgent;
|
||||
startManagedAgent: StartManagedAgent;
|
||||
}) {
|
||||
const relayMeshError = relayMeshAgentError(agent);
|
||||
if (relayMeshError) throw new Error(relayMeshError);
|
||||
await startManagedAgent(agent.pubkey);
|
||||
}
|
||||
|
||||
@@ -94,6 +108,8 @@ export async function respawnManagedAgentWithRules({
|
||||
startManagedAgent: StartManagedAgent;
|
||||
stopManagedAgent: StopManagedAgent;
|
||||
}) {
|
||||
const relayMeshError = relayMeshAgentError(agent);
|
||||
if (relayMeshError) throw new Error(relayMeshError);
|
||||
if (agent.backend.type === "local" && isManagedAgentActive(agent)) {
|
||||
await stopManagedAgent(agent.pubkey);
|
||||
}
|
||||
|
||||
@@ -396,7 +396,9 @@ export function CreateAgentDialog({
|
||||
envVars,
|
||||
model: useMesh ? meshModelId.trim() || undefined : undefined,
|
||||
spawnAfterCreate,
|
||||
startOnAppLaunch,
|
||||
// Relay-mesh agents need a freshly selected serve target to start;
|
||||
// do not auto-restore them later with only the saved model/env.
|
||||
startOnAppLaunch: useMesh ? false : startOnAppLaunch,
|
||||
backend: { type: "local" },
|
||||
...respondToFields,
|
||||
};
|
||||
|
||||
@@ -352,7 +352,9 @@ function AgentActionsMenu({
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Agent actions for ${agent.name}`}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
data-testid={`managed-agent-actions-${agent.pubkey}`}
|
||||
type="button"
|
||||
>
|
||||
<Ellipsis className="h-4 w-4" />
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { publishMeshConnectRequest } from "@/shared/api/relayMeshSignaling";
|
||||
import { getIdentity } from "@/shared/api/tauri";
|
||||
import {
|
||||
meshEnsureClientNode,
|
||||
type MeshServeTarget,
|
||||
} from "@/shared/api/tauriMesh";
|
||||
|
||||
function normalizePubkey(value: string | null | undefined): string | null {
|
||||
const normalized = value?.trim().toLowerCase() ?? "";
|
||||
return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
export async function startRelayMeshClientForTarget(
|
||||
modelId: string,
|
||||
target: MeshServeTarget | null,
|
||||
): Promise<void> {
|
||||
const status = await meshEnsureClientNode(modelId, target);
|
||||
if (!target?.reporterPubkey) {
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
"Selected relay mesh target is missing its reporter pubkey.",
|
||||
);
|
||||
}
|
||||
const targetPubkey = normalizePubkey(target.reporterPubkey);
|
||||
if (!targetPubkey) {
|
||||
throw new Error(
|
||||
"Selected relay mesh target is missing its reporter pubkey.",
|
||||
);
|
||||
@@ -17,8 +29,18 @@ export async function startRelayMeshClientForTarget(
|
||||
if (!status.inviteToken) {
|
||||
throw new Error("Local mesh client did not publish an endpoint address.");
|
||||
}
|
||||
|
||||
const selfPubkey = normalizePubkey((await getIdentity()).pubkey);
|
||||
if (selfPubkey === targetPubkey) {
|
||||
// The selected serve target belongs to this desktop. `meshEnsureClientNode`
|
||||
// has already ensured the local mesh ingress is usable; a relay
|
||||
// connect-request to ourselves would be rejected as self-targeting and is
|
||||
// unnecessary for local routing.
|
||||
return;
|
||||
}
|
||||
|
||||
await publishMeshConnectRequest({
|
||||
targetPubkey: target.reporterPubkey,
|
||||
targetPubkey,
|
||||
selfEndpointAddr: status.inviteToken,
|
||||
peerEndpointAddr: target.endpointAddr,
|
||||
attemptId: crypto.randomUUID(),
|
||||
|
||||
@@ -23,6 +23,11 @@ export async function publishMeshStatusReport(
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePubkey(value: string): string | null {
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
return /^[0-9a-f]{64}$/.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
export async function publishMeshConnectRequest(input: {
|
||||
targetPubkey: string;
|
||||
selfEndpointAddr: string;
|
||||
@@ -40,10 +45,17 @@ export async function publishMeshConnectRequest(input: {
|
||||
};
|
||||
if (input.selfEndpointId) content.self_endpoint_id = input.selfEndpointId;
|
||||
if (input.peerEndpointId) content.peer_endpoint_id = input.peerEndpointId;
|
||||
const targetPubkey = normalizePubkey(input.targetPubkey);
|
||||
if (!targetPubkey) {
|
||||
throw new Error(
|
||||
"Selected relay mesh target has an invalid reporter pubkey.",
|
||||
);
|
||||
}
|
||||
|
||||
const event = await signRelayEvent({
|
||||
kind: KIND_MESH_CONNECT_REQUEST,
|
||||
content: JSON.stringify(content),
|
||||
tags: [["p", input.targetPubkey]],
|
||||
tags: [["p", targetPubkey]],
|
||||
});
|
||||
await relayClient.publishEvent(
|
||||
event,
|
||||
|
||||
@@ -55,6 +55,7 @@ type E2eConfig = {
|
||||
// `upload_media_bytes` commands. Lets a spec drive the attachment flow
|
||||
// (e.g. a generic PDF) without a real upload pipeline. See
|
||||
// tests/helpers/bridge.ts:MockBridgeOptions.uploadDescriptors.
|
||||
meshReporterPubkey?: string;
|
||||
uploadDescriptors?: RawBlobDescriptor[];
|
||||
};
|
||||
relayHttpUrl?: string;
|
||||
@@ -514,6 +515,11 @@ declare global {
|
||||
payload?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
__SPROUT_E2E_PUSH_MOCK_FEED_ITEM__?: (item: RawFeedItem) => RawFeedItem;
|
||||
__SPROUT_E2E_SIGNED_EVENTS__?: Array<{
|
||||
content: string;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
}>;
|
||||
__SPROUT_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void;
|
||||
__SPROUT_E2E_SET_MESH__?: (mesh: {
|
||||
admitted?: boolean;
|
||||
@@ -4257,10 +4263,27 @@ function getMockManagedAgent(pubkey: string): MockManagedAgent {
|
||||
return agent;
|
||||
}
|
||||
|
||||
function isRelayMeshManagedAgent(agent: MockManagedAgent): boolean {
|
||||
const env = agent.env_vars ?? {};
|
||||
return (
|
||||
agent.backend.type === "local" &&
|
||||
env.SPROUT_AGENT_PROVIDER === "openai" &&
|
||||
env.OPENAI_COMPAT_BASE_URL?.replace(/\/+$/, "") ===
|
||||
"http://127.0.0.1:9337/v1" &&
|
||||
env.OPENAI_COMPAT_API_KEY === "sprout-mesh-local"
|
||||
);
|
||||
}
|
||||
|
||||
async function handleStartManagedAgent(args: {
|
||||
pubkey: string;
|
||||
}): Promise<RawManagedAgent> {
|
||||
const agent = getMockManagedAgent(args.pubkey);
|
||||
if (isRelayMeshManagedAgent(agent)) {
|
||||
throw new Error(
|
||||
"relay mesh agents cannot be started from saved state because the selected serve target is not persisted. Create a new agent with Run on relay mesh selected to refresh the target for http://127.0.0.1:9337/v1.",
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
agent.status = "running";
|
||||
agent.pid = agent.pid ?? 42000 + mockManagedAgents.indexOf(agent);
|
||||
@@ -5059,6 +5082,18 @@ function sendToMockSocket(args: {
|
||||
// desktop mesh flow (publishMeshConnectRequest) can proceed. We do not model
|
||||
// the paired 24622 here; that belongs in a dedicated call-me-now test.
|
||||
if (event.kind === 24620 || event.kind === 24621) {
|
||||
if (
|
||||
event.kind === 24621 &&
|
||||
!event.tags.some((tag) => tag[0] === "p" && typeof tag[1] === "string")
|
||||
) {
|
||||
sendWsText(socket.handler, [
|
||||
"OK",
|
||||
event.id,
|
||||
false,
|
||||
"invalid: mesh connect request missing #p target",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
||||
return;
|
||||
}
|
||||
@@ -5109,6 +5144,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
mockWebsocketSendMutexWedged = false;
|
||||
mockWindows("main");
|
||||
window.__SPROUT_E2E_COMMANDS__ = [];
|
||||
window.__SPROUT_E2E_SIGNED_EVENTS__ = [];
|
||||
window.__SPROUT_E2E_WEBVIEW_ZOOM__ = 1;
|
||||
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__ = ({
|
||||
channelName,
|
||||
@@ -5215,7 +5251,10 @@ export function maybeInstallE2eTauriMocks() {
|
||||
endpointAddr: "mock-endpoint-addr",
|
||||
nodeName: "Mock desktop",
|
||||
capacity: { vramGb: null },
|
||||
reporterPubkey: identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey,
|
||||
reporterPubkey:
|
||||
activeConfig?.mock?.meshReporterPubkey ??
|
||||
identity?.pubkey ??
|
||||
DEFAULT_MOCK_IDENTITY.pubkey,
|
||||
endpointId: "mock-endpoint-id",
|
||||
deviceId: "mock-endpoint-id",
|
||||
deviceName: "Mock desktop",
|
||||
@@ -5276,6 +5315,8 @@ export function maybeInstallE2eTauriMocks() {
|
||||
SPROUT_AGENT_PROVIDER: "openai",
|
||||
OPENAI_COMPAT_BASE_URL: "http://127.0.0.1:9337/v1",
|
||||
OPENAI_COMPAT_MODEL: model,
|
||||
OPENAI_COMPAT_API_KEY: "sprout-mesh-local",
|
||||
OPENAI_COMPAT_API: "chat",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5586,6 +5627,11 @@ export function maybeInstallE2eTauriMocks() {
|
||||
activeConfig,
|
||||
);
|
||||
case "sign_event":
|
||||
window.__SPROUT_E2E_SIGNED_EVENTS__?.push({
|
||||
content: (payload as { content: string }).content,
|
||||
kind: (payload as { kind: number }).kind,
|
||||
tags: (payload as { tags: string[][] }).tags,
|
||||
});
|
||||
if (identity) {
|
||||
return JSON.stringify(
|
||||
await signWithIdentity(identity, {
|
||||
|
||||
@@ -9,9 +9,15 @@ import { openSettings } from "../helpers/settings";
|
||||
// invariant and the membership-denial copy.
|
||||
|
||||
type E2eWindow = Window & {
|
||||
__SPROUT_E2E__?: { mock?: { meshReporterPubkey?: string } };
|
||||
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: unknown;
|
||||
__TAURI_INTERNALS__?: { invoke?: unknown };
|
||||
__SPROUT_E2E_COMMANDS__?: string[];
|
||||
__SPROUT_E2E_SIGNED_EVENTS__?: Array<{
|
||||
content: string;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
}>;
|
||||
__SPROUT_E2E_SET_MESH__?: (mesh: {
|
||||
admitted?: boolean;
|
||||
models?: Array<{ id: string; name: string | null }>;
|
||||
@@ -44,6 +50,13 @@ async function commands(page: import("@playwright/test").Page) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Signed event templates the bridge recorded so far. */
|
||||
async function signedEvents(page: import("@playwright/test").Page) {
|
||||
return page.evaluate(
|
||||
() => (window as E2eWindow).__SPROUT_E2E_SIGNED_EVENTS__ ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
async function setMesh(
|
||||
page: import("@playwright/test").Page,
|
||||
mesh: { admitted?: boolean; denyReason?: string },
|
||||
@@ -53,6 +66,17 @@ async function setMesh(
|
||||
}, mesh);
|
||||
}
|
||||
|
||||
async function openManagedAgentActions(
|
||||
page: import("@playwright/test").Page,
|
||||
pubkey: string,
|
||||
) {
|
||||
const trigger = page.getByTestId(`managed-agent-actions-${pubkey}`);
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
await trigger.focus();
|
||||
await trigger.press("Enter");
|
||||
await expect(trigger).toHaveAttribute("data-state", "open");
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
@@ -157,6 +181,80 @@ test("Run-on-relay-mesh ensures the client node BEFORE spawning the agent", asyn
|
||||
expect(ensureIdx).toBeLessThan(createIdx);
|
||||
});
|
||||
|
||||
test("Run-on-relay-mesh skips connect signaling for own serve target", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByRole("button", { name: "New" }).click();
|
||||
await page.getByText("Custom Agent").click();
|
||||
await page.getByTestId("agent-name-input").fill("Own Mesh Agent");
|
||||
|
||||
const toggle = page.getByTestId("agent-relay-mesh-toggle");
|
||||
await expect(toggle).toBeEnabled({ timeout: 10_000 });
|
||||
await toggle.click();
|
||||
await page
|
||||
.getByTestId("agent-relay-mesh-model")
|
||||
.selectOption({ label: "SmolLM2 135M — Mock desktop" });
|
||||
|
||||
const before = (await commands(page)).length;
|
||||
await page.getByTestId("create-agent-submit").click();
|
||||
await expect
|
||||
.poll(async () => (await commands(page)).slice(before))
|
||||
.toContain("create_managed_agent");
|
||||
|
||||
const slice = (await commands(page)).slice(before);
|
||||
expect(slice).toContain("mesh_ensure_client_node");
|
||||
expect(
|
||||
(await signedEvents(page)).filter((event) => event.kind === 24621),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Run-on-relay-mesh canonicalizes the mesh connect #p target", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
const w = window as E2eWindow;
|
||||
w.__SPROUT_E2E__ = {
|
||||
...(w.__SPROUT_E2E__ ?? {}),
|
||||
mock: {
|
||||
...(w.__SPROUT_E2E__?.mock ?? {}),
|
||||
meshReporterPubkey:
|
||||
" CAFEBABECAFEBABECAFEBABECAFEBABECAFEBABECAFEBABECAFEBABECAFEBABE ",
|
||||
},
|
||||
};
|
||||
});
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByRole("button", { name: "New" }).click();
|
||||
await page.getByText("Custom Agent").click();
|
||||
await page.getByTestId("agent-name-input").fill("Mesh Agent");
|
||||
|
||||
const toggle = page.getByTestId("agent-relay-mesh-toggle");
|
||||
await expect(toggle).toBeEnabled({ timeout: 10_000 });
|
||||
await toggle.click();
|
||||
await page
|
||||
.getByTestId("agent-relay-mesh-model")
|
||||
.selectOption({ label: "SmolLM2 135M — Mock desktop" });
|
||||
|
||||
await page.getByTestId("create-agent-submit").click();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await signedEvents(page)).find((event) => event.kind === 24621),
|
||||
)
|
||||
.toMatchObject({
|
||||
tags: [
|
||||
[
|
||||
"p",
|
||||
"cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe",
|
||||
],
|
||||
],
|
||||
});
|
||||
await expect
|
||||
.poll(async () => await commands(page))
|
||||
.toContain("create_managed_agent");
|
||||
});
|
||||
|
||||
test("a non-member cannot enable relay-mesh — membership is the gate", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -179,3 +277,89 @@ test("a non-member cannot enable relay-mesh — membership is the gate", async (
|
||||
const seq = await commands(page);
|
||||
expect(seq).not.toContain("create_managed_agent");
|
||||
});
|
||||
|
||||
test("saved relay-mesh agents require a fresh serve target before manual start", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByRole("button", { name: "New" }).click();
|
||||
await page.getByText("Custom Agent").click();
|
||||
await page.getByTestId("agent-name-input").fill("Saved relay mesh agent");
|
||||
|
||||
const toggle = page.getByTestId("agent-relay-mesh-toggle");
|
||||
await expect(toggle).toBeEnabled({ timeout: 10_000 });
|
||||
await toggle.click();
|
||||
await page
|
||||
.getByTestId("agent-relay-mesh-model")
|
||||
.selectOption({ label: "SmolLM2 135M — Mock desktop" });
|
||||
|
||||
await page.getByTestId("create-agent-submit").click();
|
||||
await expect
|
||||
.poll(async () => await commands(page))
|
||||
.toContain("create_managed_agent");
|
||||
|
||||
const agents = await page.evaluate(async () => {
|
||||
const w = window as E2eWindow & {
|
||||
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: (
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => Promise<Array<{ name: string; pubkey: string }>>;
|
||||
};
|
||||
const invoke = w.__SPROUT_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
|
||||
return invoke("list_managed_agents");
|
||||
});
|
||||
const pubkey = agents.find(
|
||||
(agent) => agent.name === "Saved relay mesh agent",
|
||||
)?.pubkey;
|
||||
expect(pubkey).toBeTruthy();
|
||||
|
||||
const row = page.getByTestId(`managed-agent-${pubkey}`);
|
||||
await expect(row).toContainText("Saved relay mesh agent");
|
||||
await expect(row).toContainText("running");
|
||||
await page.getByRole("button", { name: "Done" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Agent created" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await openManagedAgentActions(page, pubkey);
|
||||
await page.getByRole("menuitem", { name: "Stop" }).click();
|
||||
await expect
|
||||
.poll(async () => await commands(page))
|
||||
.toContain("stop_managed_agent");
|
||||
await expect(row).toContainText("stopped");
|
||||
|
||||
const before = (await commands(page)).length;
|
||||
await openManagedAgentActions(page, pubkey);
|
||||
await page.getByRole("menuitem", { name: "Spawn" }).click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-sonner-toast]")
|
||||
.filter({ hasText: "Relay-mesh agents need a fresh serve target" }),
|
||||
).toBeVisible();
|
||||
expect((await commands(page)).slice(before)).not.toContain(
|
||||
"start_managed_agent",
|
||||
);
|
||||
await expect(row).toContainText("stopped");
|
||||
|
||||
await expect(
|
||||
page.evaluate(async (agentPubkey) => {
|
||||
const invoke = (window as E2eWindow).__SPROUT_E2E_INVOKE_MOCK_COMMAND__ as
|
||||
| ((
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => Promise<unknown>)
|
||||
| undefined;
|
||||
if (!invoke) throw new Error("Mock invoke bridge is unavailable.");
|
||||
try {
|
||||
await invoke("start_managed_agent", { pubkey: agentPubkey });
|
||||
return "started";
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}, pubkey),
|
||||
).resolves.toContain("selected serve target is not persisted");
|
||||
await expect(row).toContainText("stopped");
|
||||
});
|
||||
|
||||
@@ -70,6 +70,11 @@ type MockBridgeOptions = {
|
||||
* evaluates false).
|
||||
*/
|
||||
relayRole?: "owner" | "admin" | "member" | null;
|
||||
/**
|
||||
* Reporter pubkey injected into mocked mesh serve targets. Defaults to the
|
||||
* active identity; specs can override to catch malformed/missing #p handling.
|
||||
*/
|
||||
meshReporterPubkey?: string;
|
||||
/**
|
||||
* Descriptors returned by the mocked `pick_and_upload_media` /
|
||||
* `upload_media_bytes` commands. When omitted, the bridge returns a single
|
||||
|
||||
Reference in New Issue
Block a user