use synaps_cli::sidecar::discovery::{discover, DiscoveredSidecar};
use synaps_cli::sidecar::manager::{SidecarError, SidecarLifecycleEvent, SidecarManager};
use synaps_cli::sidecar::protocol::{InsertTextMode, SIDECAR_PROTOCOL_VERSION};
use synaps_cli::sidecar::spawn::SidecarSpawnArgs;
use super::app::{App, ChatMessage};
type ExtensionManager =
std::sync::Arc<tokio::sync::RwLock<synaps_cli::extensions::manager::ExtensionManager>>;
type CommandRegistry = synaps_cli::skills::registry::CommandRegistry;
const STARTUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub(crate) struct SidecarStartup {
pub task: tokio::task::JoinHandle<Result<SidecarUiState, String>>,
pub sidecar: DiscoveredSidecar,
pub label: String,
}
impl Drop for SidecarStartup {
fn drop(&mut self) {
self.task.abort();
}
}
impl SidecarStartup {
pub fn start(
sidecar: DiscoveredSidecar,
has_extension: bool,
label: String,
manager: ExtensionManager,
) -> Self {
let discovered = sidecar.clone();
let display_name = label.clone();
let task = tokio::spawn(async move {
tokio::time::timeout(STARTUP_TIMEOUT, async move {
let (info, handler) = if has_extension {
let manager = manager.read().await;
(
manager.plugin_info(&discovered.plugin_name).cloned(),
Some(manager.user_action_handler(&discovered.plugin_name)?),
)
} else {
(None, None)
};
let args = if let Some(handler) = handler {
match handler.sidecar_spawn_args().await {
Ok(args) => Some(args),
Err(error) if spawn_args_unsupported(&error) => None,
Err(error) => return Err(error),
}
} else {
None
};
let mut state = SidecarUiState::spawn_for(discovered, args, info.as_ref()).await?;
state.set_display_name(Some(display_name));
Ok(state)
})
.await
.map_err(|_| "sidecar startup timed out after 30s; toggle to retry".to_string())?
});
Self {
task,
sidecar,
label,
}
}
}
fn spawn_args_unsupported(error: &str) -> bool {
matches!(
error,
"extension runtime does not support sidecar.spawn_args"
| "Extension error: method not found"
| "Extension error: unknown method"
| "Extension error: unknown method: sidecar.spawn_args"
)
}
fn loading_message(label: &str) -> String {
format!("{label}: still loading — try the toggle again when ready")
}
pub(crate) async fn toggle(
app: &mut App,
plugin_id: Option<String>,
registry: &CommandRegistry,
manager: &ExtensionManager,
) {
if app.sidecars_disabled {
app.push_msg(ChatMessage::System(
"Sidecars are disabled (--no-extensions).".into(),
));
return;
}
let all = registry.sidecars();
let target = plugin_id.or_else(|| all.first().map(|s| s.plugin_name.clone()));
let Some(pid) = target else {
app.push_msg(ChatMessage::Error(
"sidecar unavailable: no enabled plugin provides a sidecar binary".into(),
));
return;
};
if let Some(pending) = app.sidecar_starts.get(&pid) {
app.push_msg(ChatMessage::System(loading_message(&pending.label)));
return;
}
let Some(discovered) = all.into_iter().find(|s| s.plugin_name == pid) else {
app.sidecars.remove(&pid);
app.push_msg(ChatMessage::Error(format!(
"sidecar plugin '{pid}' is not enabled or discoverable"
)));
return;
};
drain_events(app, &pid);
if app.sidecars.get(&pid).is_some_and(|state| {
state.sidecar != discovered || matches!(state.status, SidecarUiStatus::Error(_))
}) {
app.sidecars.remove(&pid);
}
if let Some(state) = app.sidecars.get_mut(&pid) {
let label = state.display_name.clone().unwrap_or_else(|| pid.clone());
if matches!(state.status, SidecarUiStatus::Loading) {
app.push_msg(ChatMessage::System(loading_message(&label)));
return;
}
if state.armed {
state.armed = false;
match state.manager.release().await {
Ok(()) => app.push_msg(ChatMessage::System(format!(
"{label}: stopping — final transcript will be appended"
))),
Err(error) => {
state.status = SidecarUiStatus::Error(error.to_string());
app.push_msg(ChatMessage::Error(format!(
"{label} release failed: {error}"
)));
}
}
} else {
match state.manager.press().await {
Ok(()) => {
state.armed = true;
app.push_msg(ChatMessage::System(format!(
"{label} active — toggle again to stop"
)));
}
Err(error) => {
state.status = SidecarUiStatus::Error(error.to_string());
app.push_msg(ChatMessage::Error(format!("{label} press failed: {error}")));
}
}
}
return;
}
let label = super::loop_arms::pick_display_name_for_plugin(&pid, ®istry.lifecycle_claims())
.unwrap_or_else(|| pid.clone());
if registry.sidecar_has_extension(&pid) && app.extension_loader_running {
app.push_msg(ChatMessage::System(loading_message(&label)));
return;
}
let startup = SidecarStartup::start(
discovered,
registry.sidecar_has_extension(&pid),
label.clone(),
manager.clone(),
);
app.sidecar_starts.insert(pid, startup);
app.push_msg(ChatMessage::System(loading_message(&label)));
}
pub(crate) async fn next_startup(
starts: &mut std::collections::HashMap<String, SidecarStartup>,
) -> (String, Result<SidecarUiState, String>) {
if starts.is_empty() {
return std::future::pending().await;
}
let futures: Vec<_> = starts
.iter_mut()
.map(|(pid, start)| {
let pid = pid.clone();
Box::pin(async move {
let result = (&mut start.task)
.await
.unwrap_or_else(|_| Err("sidecar startup task failed; toggle to retry".into()));
(pid, result)
})
})
.collect();
futures::future::select_all(futures).await.0
}
pub(crate) fn finish_startup(
app: &mut App,
registry: &CommandRegistry,
pid: String,
result: Result<SidecarUiState, String>,
) {
let Some(pending) = app.sidecar_starts.remove(&pid) else {
return;
};
if app.sidecars_disabled || !registry.sidecars().contains(&pending.sidecar) {
app.push_msg(ChatMessage::System(format!(
"{} startup cancelled: plugin changed or disabled",
pending.label
)));
return;
}
match result {
Ok(state) => {
debug_assert!(!state.armed);
app.sidecars.insert(pid.clone(), state);
drain_events(app, &pid);
if app
.sidecars
.get(&pid)
.is_some_and(|state| matches!(state.status, SidecarUiStatus::Idle))
{
app.push_msg(ChatMessage::System(format!(
"{} ready — toggle to activate",
pending.label
)));
}
}
Err(error) => app.push_msg(ChatMessage::Error(format!(
"{} unavailable: {error}",
pending.label
))),
}
}
fn drain_events(app: &mut App, pid: &str) {
for _ in 0..64 {
let Some(event) = app
.sidecars
.get_mut(pid)
.and_then(|s| s.manager.try_next_event())
else {
return;
};
let failed = matches!(
event,
SidecarLifecycleEvent::Error(_) | SidecarLifecycleEvent::Exited
);
handle_event(app, pid, event);
if failed || !app.sidecars.contains_key(pid) {
return;
}
}
if let Some(state) = app.sidecars.get_mut(pid) {
state.status = SidecarUiStatus::Loading;
}
}
pub(crate) fn retain_enabled(app: &mut App, registry: &CommandRegistry) {
let enabled = registry.sidecars();
app.sidecar_starts
.retain(|_, pending| enabled.contains(&pending.sidecar));
app.sidecars
.retain(|_, state| enabled.contains(&state.sidecar));
}
pub(crate) fn status(app: &App, plugin_id: Option<&str>, registry: &CommandRegistry) -> String {
if app.sidecars_disabled {
return "Sidecars are disabled (--no-extensions).".into();
}
let mut lines = Vec::new();
for sidecar in registry
.sidecars()
.into_iter()
.filter(|s| plugin_id.map_or(true, |p| p == s.plugin_name))
{
let pid = &sidecar.plugin_name;
lines.push(if let Some(pending) = app.sidecar_starts.get(pid) {
loading_message(&pending.label)
} else if let Some(state) = app.sidecars.get(pid) {
state.status_line()
} else {
format!("{pid}: not yet started — toggle to load in the background")
});
}
lines.sort();
if lines.is_empty() {
"sidecar: no enabled plugin provides the requested sidecar".into()
} else {
lines.join("\n")
}
}
pub(crate) async fn next_event(
sidecars: &mut std::collections::HashMap<String, SidecarUiState>,
) -> (String, SidecarLifecycleEvent) {
if sidecars.is_empty() {
return std::future::pending().await;
}
let futures: Vec<_> = sidecars
.iter_mut()
.map(|(pid, state)| {
let pid = pid.clone();
Box::pin(async move {
let event = state
.manager
.next_event()
.await
.unwrap_or(SidecarLifecycleEvent::Exited);
(pid, event)
})
})
.collect();
futures::future::select_all(futures).await.0
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SidecarUiStatus {
Idle,
Loading,
Active { label: String },
Error(String),
}
pub(crate) struct SidecarUiState {
pub manager: SidecarManager,
pub status: SidecarUiStatus,
pub sidecar: DiscoveredSidecar,
pub armed: bool,
pub compiled_backend: Option<String>,
pub display_name: Option<String>,
}
impl SidecarUiState {
#[allow(dead_code)] pub async fn spawn_default() -> Result<Self, String> {
Self::spawn_with(None, None).await
}
#[allow(dead_code)] pub async fn spawn_default_with_plugin_info(
plugin_info: Option<&synaps_cli::extensions::info::PluginInfo>,
) -> Result<Self, String> {
Self::spawn_with(None, plugin_info).await
}
pub async fn spawn_with(
spawn_args: Option<SidecarSpawnArgs>,
plugin_info: Option<&synaps_cli::extensions::info::PluginInfo>,
) -> Result<Self, String> {
let sidecar = discover().ok_or_else(|| {
"no plugin provides a sidecar binary; install a sidecar-providing plugin from synaps-skills"
.to_string()
})?;
Self::spawn_for(sidecar, spawn_args, plugin_info).await
}
pub async fn spawn_for(
sidecar: DiscoveredSidecar,
spawn_args: Option<SidecarSpawnArgs>,
plugin_info: Option<&synaps_cli::extensions::info::PluginInfo>,
) -> Result<Self, String> {
if !sidecar.binary.is_file() {
return Err(format!(
"sidecar binary not found at {} — run the plugin's setup.sh first",
sidecar.binary.display()
));
}
let args = build_spawn_args(&sidecar, spawn_args);
let config = serde_json::json!({
"protocol_version": SIDECAR_PROTOCOL_VERSION,
});
let mut manager = SidecarManager::spawn(&sidecar.binary, &args, config)
.await
.map_err(|err: SidecarError| format!("failed to start sidecar: {}", err))?;
if manager.ready_after_init() {
tokio::time::timeout(STARTUP_TIMEOUT, async {
loop {
match manager.next_event().await {
Some(SidecarLifecycleEvent::StateChanged { state, .. })
if state == "ready" =>
{
return Ok(())
}
Some(SidecarLifecycleEvent::Error(error)) => return Err(error),
Some(SidecarLifecycleEvent::Exited) | None => {
return Err("sidecar exited while loading".to_string())
}
_ => {}
}
}
})
.await
.map_err(|_| "sidecar did not report ready after Init within 30s".to_string())??;
}
let compiled_backend = plugin_info
.and_then(|info| info.build.as_ref())
.map(|b| b.backend.clone());
Ok(Self {
manager,
status: SidecarUiStatus::Idle,
sidecar,
armed: false,
compiled_backend,
display_name: None,
})
}
pub fn set_display_name(&mut self, name: Option<String>) {
self.display_name = name;
}
pub fn status_line(&self) -> String {
format_status_line(
self.display_name.as_deref(),
&self.status,
&self.sidecar.plugin_name,
&self.sidecar.binary.display().to_string(),
self.compiled_backend.as_deref(),
)
}
}
fn format_status_line(
display_name: Option<&str>,
status: &SidecarUiStatus,
plugin_name: &str,
binary_path: &str,
backend: Option<&str>,
) -> String {
let label = display_name.unwrap_or("sidecar");
let state = match status {
SidecarUiStatus::Idle => "idle".to_string(),
SidecarUiStatus::Loading => "loading".to_string(),
SidecarUiStatus::Active { label } => label.clone(),
SidecarUiStatus::Error(msg) => return format!("{label}: error — {msg}"),
};
format!(
"{}: {} ({}) — process: {} | backend: {}",
label,
state,
plugin_name,
binary_path,
backend.unwrap_or("unknown"),
)
}
pub(crate) fn handle_event(app: &mut App, plugin_id: &str, event: SidecarLifecycleEvent) {
let Some(v) = app.sidecars.get_mut(plugin_id) else {
return;
};
match event {
SidecarLifecycleEvent::Ready { .. } => {
}
SidecarLifecycleEvent::StateChanged { state, label } => {
let is_inactive = matches!(state.as_str(), "idle" | "ready" | "stopped");
if matches!(state.as_str(), "loading" | "initializing") && !v.armed {
v.status = SidecarUiStatus::Loading;
} else if is_inactive {
if !v.armed {
v.status = SidecarUiStatus::Idle;
}
} else {
v.status = SidecarUiStatus::Active {
label: label.unwrap_or(state),
};
}
}
SidecarLifecycleEvent::InsertText { text, mode } => match mode {
InsertTextMode::Append => {
}
InsertTextMode::Final | InsertTextMode::Replace => {
let armed = v.armed;
insert_text_into_input(app, &text);
if !armed {
if let Some(v) = app.sidecars.get_mut(plugin_id) {
v.status = SidecarUiStatus::Idle;
}
}
}
},
SidecarLifecycleEvent::Error(message) => {
v.status = SidecarUiStatus::Error(message.clone());
app.push_msg(ChatMessage::Error(format!("sidecar error: {}", message)));
}
SidecarLifecycleEvent::Exited => {
let label = app
.sidecars
.get(plugin_id)
.and_then(|s| s.display_name.clone())
.unwrap_or_else(|| "sidecar".to_string());
app.push_msg(ChatMessage::System(format!("{label} exited")));
app.sidecars.remove(plugin_id);
}
}
}
pub(crate) fn insert_text_into_input(app: &mut App, text: &str) {
let trimmed = text.trim();
if trimmed.is_empty() {
return;
}
let needs_leading_space = {
let (row, col) = app.editor.cursor();
col > 0
&& app
.editor
.lines()
.get(row)
.and_then(|line| line.chars().nth(col - 1))
.is_some_and(|c| !c.is_whitespace())
};
let to_insert = if needs_leading_space {
format!(" {}", trimmed)
} else {
trimmed.to_string()
};
app.insert_at_cursor(&to_insert);
app.invalidate();
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)] mod tests {
use super::*;
use synaps_cli::Session;
fn fresh_app() -> App {
App::new(Session::new("test", "medium", None))
}
#[test]
fn insert_text_into_empty_input() {
let mut app = fresh_app();
insert_text_into_input(&mut app, "hello world");
assert_eq!(app.input_text(), "hello world");
assert_eq!(app.cursor_char_pos(), "hello world".chars().count());
}
fn discovered(default_model: Option<&str>) -> DiscoveredSidecar {
use synaps_cli::skills::manifest::SidecarModel;
DiscoveredSidecar {
plugin_name: "anything".into(),
plugin_root: std::path::PathBuf::from("/opt/anything"),
binary: std::path::PathBuf::from("/opt/anything/bin/sidecar"),
protocol_version: 1,
setup_script: None,
model: default_model.map(|p| SidecarModel {
default_path: Some(p.to_string()),
required: false,
}),
lifecycle: None,
}
}
#[test]
fn build_spawn_args_uses_plugin_args_verbatim() {
let sidecar = discovered(None);
let args = SidecarSpawnArgs {
args: vec!["--foo".into(), "bar".into()],
language: Some("fr".into()),
};
let out_args = build_spawn_args(&sidecar, Some(args));
assert_eq!(out_args, vec!["--foo", "bar"]);
}
#[test]
fn build_spawn_args_appends_manifest_default_when_file_exists() {
let cargo_toml = std::env::current_dir().unwrap().join("Cargo.toml");
let path_str = cargo_toml.to_string_lossy().into_owned();
let sidecar = discovered(Some(&path_str));
let out_args = build_spawn_args(&sidecar, None);
assert_eq!(out_args.len(), 2);
assert_eq!(out_args[0], "--model-path");
assert_eq!(out_args[1], path_str);
}
#[test]
fn build_spawn_args_skips_manifest_default_when_file_missing() {
let sidecar = discovered(Some("/definitely/not/a/real/path/xyz.bin"));
let out_args = build_spawn_args(&sidecar, None);
assert!(
out_args.is_empty(),
"missing default file must not produce args, got {out_args:?}"
);
}
#[test]
fn build_spawn_args_does_not_double_up_model_path() {
let cargo_toml = std::env::current_dir().unwrap().join("Cargo.toml");
let sidecar = discovered(Some(&cargo_toml.to_string_lossy()));
let plugin_args = SidecarSpawnArgs {
args: vec!["--model-path".into(), "/plugin/chosen.bin".into()],
language: None,
};
let out_args = build_spawn_args(&sidecar, Some(plugin_args));
let count = out_args.iter().filter(|a| *a == "--model-path").count();
assert_eq!(count, 1);
assert_eq!(out_args, vec!["--model-path", "/plugin/chosen.bin"]);
}
#[test]
fn build_spawn_args_returns_empty_when_no_plugin_args_and_no_manifest_default() {
let sidecar = discovered(None);
let out_args = build_spawn_args(&sidecar, None);
assert!(out_args.is_empty());
}
#[test]
fn build_spawn_args_with_none_spawn_args_falls_back_to_manifest() {
let cargo_toml = std::env::current_dir().unwrap().join("Cargo.toml");
let sidecar = discovered(Some(&cargo_toml.to_string_lossy()));
let out_args = build_spawn_args(&sidecar, None);
assert_eq!(out_args[0], "--model-path");
}
#[test]
fn insert_text_appends_with_leading_space() {
let mut app = fresh_app();
app.set_input_text("first");
insert_text_into_input(&mut app, "second sentence");
assert_eq!(app.input_text(), "first second sentence");
assert_eq!(
app.cursor_char_pos(),
"first second sentence".chars().count()
);
}
#[test]
fn insert_text_no_double_space_when_input_ends_with_space() {
let mut app = fresh_app();
app.set_input_text("first ");
insert_text_into_input(&mut app, "second");
assert_eq!(app.input_text(), "first second");
}
#[test]
fn insert_text_trims_whitespace_from_payload() {
let mut app = fresh_app();
insert_text_into_input(&mut app, " spaced text ");
assert_eq!(app.input_text(), "spaced text");
}
#[test]
fn insert_text_ignores_empty_or_whitespace_only() {
let mut app = fresh_app();
insert_text_into_input(&mut app, "");
insert_text_into_input(&mut app, " ");
assert_eq!(app.input_text(), "");
assert_eq!(app.cursor_char_pos(), 0);
}
#[test]
fn insert_text_inserts_at_cursor_not_end() {
let mut app = fresh_app();
app.set_input_text("hello world");
app.editor.move_cursor(tui_textarea::CursorMove::Jump(0, 5));
insert_text_into_input(&mut app, "beautiful");
assert_eq!(app.input_text(), "hello beautiful world");
}
#[test]
fn status_line_uses_display_name_when_set() {
let line = format_status_line(
Some("Sensor"),
&SidecarUiStatus::Idle,
"sample-sidecar",
"/opt/sample-sidecar/bin/sidecar",
Some("metal"),
);
assert!(line.starts_with("Sensor:"), "got: {line}");
}
#[test]
fn status_line_falls_back_to_sidecar_when_no_display_name() {
let line = format_status_line(
None,
&SidecarUiStatus::Idle,
"sample-sidecar",
"/opt/sample-sidecar/bin/sidecar",
Some("metal"),
);
assert!(line.starts_with("sidecar:"), "got: {line}");
}
#[test]
fn status_line_uses_display_name_for_error_state() {
let line = format_status_line(
Some("Sensor"),
&SidecarUiStatus::Error("oops".into()),
"sample-sidecar",
"/opt/sample-sidecar/bin/sidecar",
None,
);
assert_eq!(line, "Sensor: error — oops");
}
#[test]
fn status_line_shows_loading() {
let line = format_status_line(
Some("Voice"),
&SidecarUiStatus::Loading,
"voice-plugin",
"/opt/voice/bin/sidecar",
None,
);
assert!(line.contains("loading"), "got: {line}");
assert!(line.starts_with("Voice:"), "got: {line}");
}
#[test]
fn only_unsupported_spawn_args_can_use_defaults() {
assert!(spawn_args_unsupported("Extension error: method not found"));
assert!(spawn_args_unsupported(
"extension runtime does not support sidecar.spawn_args"
));
for error in [
"activation denied",
"sidecar.spawn_args timed out",
"invalid response",
"denied -32601",
] {
assert!(!spawn_args_unsupported(error));
}
}
#[cfg(unix)]
mod async_startup {
use super::*;
use std::os::unix::fs::PermissionsExt;
use std::{path::Path, sync::Arc, time::Duration};
fn fixture() -> (tempfile::TempDir, synaps_cli::skills::Plugin) {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("sidecar.py");
std::fs::write(
&bin,
r#"#!/usr/bin/env python3
import json, os, pathlib, sys, time
root = pathlib.Path(__file__).parent
(root / 'pid').write_text(str(os.getpid()))
with (root / 'spawns').open('a') as f: f.write('spawn\n')
while not (root / 'hello').exists(): time.sleep(.01)
print(json.dumps({'type':'hello','protocol_version':2,'extension':'test-sidecar','capabilities':['ready_after_init']}), flush=True)
for line in sys.stdin:
msg = json.loads(line)
with (root / 'commands').open('a') as f: f.write(line)
if msg['type'] == 'init':
while not (root / 'ready').exists(): time.sleep(.01)
print(json.dumps({'type':'status','state':'ready'}), flush=True)
elif msg['type'] == 'shutdown': break
"#,
)
.unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o700)).unwrap();
let manifest = serde_json::from_value(serde_json::json!({
"name": "test-sidecar",
"provides": {"sidecar": {"command": "sidecar.py", "protocol_version": 2}}
}))
.unwrap();
let plugin = synaps_cli::skills::Plugin {
name: "test-sidecar".into(),
root: dir.path().to_path_buf(),
marketplace: None,
version: None,
description: None,
extension: None,
manifest: Some(manifest),
};
(dir, plugin)
}
fn registry(plugin: synaps_cli::skills::Plugin) -> CommandRegistry {
CommandRegistry::new_with_plugins(&[], vec![], vec![plugin])
}
fn manager() -> ExtensionManager {
let mut manager = synaps_cli::extensions::manager::ExtensionManager::new(Arc::new(
synaps_cli::extensions::hooks::HookBus::new(),
));
manager.bind_memory_backend(false);
Arc::new(tokio::sync::RwLock::new(manager))
}
#[allow(dead_code)] async fn wait_for(mut condition: impl FnMut() -> bool) {
tokio::time::timeout(Duration::from_secs(5), async {
while !condition() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("fixture condition timed out");
}
#[allow(dead_code)] fn text(path: &Path) -> String {
std::fs::read_to_string(path).unwrap_or_default()
}
async fn complete(app: &mut App, registry: &CommandRegistry) {
let (pid, result) = tokio::time::timeout(
Duration::from_secs(5),
next_startup(&mut app.sidecar_starts),
)
.await
.expect("startup should finish");
finish_startup(app, registry, pid, result);
}
#[tokio::test]
async fn delayed_completion_drains_loading_before_allowing_a_trigger() {
let (dir, plugin) = fixture();
std::fs::write(dir.path().join("hello"), "go").unwrap();
std::fs::write(dir.path().join("ready"), "go").unwrap();
let registry = registry(plugin);
let mut app = fresh_app();
toggle(&mut app, None, ®istry, &manager()).await;
complete(&mut app, ®istry).await;
assert!(app.sidecar_starts.is_empty());
assert!(!app.sidecars["test-sidecar"].armed);
}
#[tokio::test]
async fn disable_cancels_both_loading_and_live_sidecars() {
let (dir, plugin) = fixture();
std::fs::write(dir.path().join("hello"), "go").unwrap();
std::fs::write(dir.path().join("ready"), "go").unwrap();
let registry = registry(plugin);
let manager = manager();
let mut app = fresh_app();
toggle(&mut app, None, ®istry, &manager).await;
complete(&mut app, ®istry).await;
assert!(app.sidecars.contains_key("test-sidecar"));
app.sidecars_disabled = true;
toggle(&mut app, None, ®istry, &manager).await;
assert!(app.sidecar_starts.is_empty());
}
#[tokio::test]
async fn disabled_sidecars_and_registry_removal_never_start() {
let (dir, plugin) = fixture();
let registry = registry(plugin);
let manager = manager();
let mut app = fresh_app();
app.sidecars_disabled = true;
toggle(&mut app, None, ®istry, &manager).await;
assert!(app.sidecar_starts.is_empty());
assert!(status(&app, None, ®istry).contains("disabled"));
app.sidecars_disabled = false;
registry.rebuild_with_plugins(vec![], vec![]);
toggle(&mut app, Some("test-sidecar".into()), ®istry, &manager).await;
assert!(app.sidecar_starts.is_empty());
assert!(!dir.path().join("pid").exists());
}
}
}
fn expand_tilde(path: String) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
let mut full = std::path::PathBuf::from(home);
full.push(rest);
return full.to_string_lossy().into_owned();
}
}
path
}
fn build_spawn_args(
sidecar: &DiscoveredSidecar,
spawn_args: Option<SidecarSpawnArgs>,
) -> Vec<String> {
let mut args: Vec<String> = Vec::new();
if let Some(plugin_args) = spawn_args {
args.extend(plugin_args.args);
}
let already_has_model_path = args.iter().any(|a| a == "--model-path");
if !already_has_model_path {
if let Some(default_path) = sidecar
.model
.as_ref()
.and_then(|m| m.default_path.clone())
.map(expand_tilde)
{
if std::path::Path::new(&default_path).is_file() {
args.push("--model-path".to_string());
args.push(default_path);
}
}
}
args
}