1use std::path::PathBuf;
10use std::sync::Arc;
11
12use agent_engine::session::socket_transport::SocketTransport;
13use agent_engine::session::wire::{Attach, Hello};
14use agent_engine::session::{
15 AttachMode, ClientKind, ClientTransport, CompactionPolicyWire, EndReason, HistoryMode,
16 SessionCommand, SessionConfig, SessionId, TransportError,
17};
18use synaps_cli::skills::registry::CommandRegistry;
19use synaps_cli::skills::BUILTIN_COMMANDS;
20use synaps_cli::Result;
21
22use agent_core::core::memstat::ladder;
23
24use super::app::ChatMessage;
25use super::run_setup::{app_from_snapshot, finish_setup, LazyHttp, TransportMode};
26
27pub struct AttachOpts {
29 pub profile: Option<String>,
30 pub id: Option<String>,
32 pub continue_session: Option<Option<String>>,
38 pub system: Option<String>,
39 pub prompt_manifest: Option<PathBuf>,
40 pub mode: AttachMode,
41 pub no_confirm_answers: bool,
46 pub keep_warm: bool,
47 pub new_session: bool,
50 pub name: Option<String>,
53 pub client_name: Option<String>,
56 pub adopted: bool,
60}
61
62const ATTACH_TOAST_ID: &str = "attach";
64
65struct AttachNoticeInputs<'a> {
67 session_id: &'a SessionId,
68 client_id: u64,
69 mode: AttachMode,
70 owned_elsewhere: Option<String>,
72 adopted: Option<bool>,
74 others_banner: Option<String>,
76 config_warnings: &'a [String],
77 version_skew: Option<String>,
80}
81
82#[derive(Debug, Default, PartialEq, Eq)]
84struct AttachNotices {
85 transcript: Vec<String>,
89 toast: Vec<String>,
93}
94
95fn attach_notices(i: AttachNoticeInputs<'_>) -> AttachNotices {
96 let mut out = AttachNotices::default();
97 if let Some(continued) = i.adopted {
98 let how = if continued { "continued session" } else { "fresh session" };
99 out.toast.push(format!("adopted the running daemon ({how})"));
100 out.toast.push("SYNAPS_DAEMON_ADOPT=0 for in-process".to_string());
101 out.transcript.extend(i.others_banner);
102 }
103 for w in i.config_warnings {
104 out.transcript.push(format!("⚠ config: {w}"));
105 }
106 if let Some(skew) = i.version_skew {
107 out.transcript.push(format!("⚠ {skew}"));
108 }
109 let attached = format!("attached to {} as client #{} ({:?})", i.session_id, i.client_id, i.mode);
110 match i.owned_elsewhere {
111 Some(owned) if i.mode != AttachMode::Observe => {
114 out.transcript.push(format!("{attached} — {owned}, or /takeover here"))
115 }
116 Some(owned) => out.transcript.push(format!("{attached} — {owned}")),
117 None => out.toast.insert(0, attached),
118 }
119 out
120}
121
122pub fn adopt_banner(
129 sessions: &[agent_engine::session::SessionMeta],
130 ours: &SessionId,
131 now: chrono::DateTime<chrono::Utc>,
132) -> Option<String> {
133 let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
134 adopt_banner_with_home(sessions, ours, now, home.as_deref())
135}
136
137fn adopt_banner_with_home(
140 sessions: &[agent_engine::session::SessionMeta],
141 ours: &SessionId,
142 now: chrono::DateTime<chrono::Utc>,
143 home: Option<&std::path::Path>,
144) -> Option<String> {
145 use agent_engine::session::SessionLifecycle as L;
146 let mut others: Vec<&agent_engine::session::SessionMeta> =
147 sessions.iter().filter(|m| &m.id != ours).collect();
148 if others.is_empty() {
149 return None;
150 }
151 others.sort_by_key(|m| std::cmp::Reverse(m.created_at));
152 let n = others.len();
153 let parked = others.iter().filter(|m| matches!(m.lifecycle, L::Parked)).count();
154 let live = n - parked;
155 let counts: Vec<String> = [(live, "live"), (parked, "parked")]
156 .into_iter()
157 .filter(|(k, _)| *k > 0)
158 .map(|(k, what)| format!("{k} {what}"))
159 .collect();
160 let mut line = format!(
161 "{n} other session{} in this daemon ({}) \u{2014} /sessions to switch:",
162 if n == 1 { "" } else { "s" },
163 counts.join(", ")
164 );
165 for m in others.iter().take(5) {
166 let attached = if m.clients > 0 { " \u{00b7} attached" } else { "" };
167 let cwd = super::sessions::short_cwd(m.cwd.as_deref(), home);
168 line.push_str(&format!(
169 "\n {} {}{attached} {cwd}",
170 super::sessions::label(m),
171 super::sessions::state_text(m, now)
172 ));
173 }
174 if n > 5 {
175 line.push_str(&format!("\n \u{2026} and {} more", n - 5));
176 }
177 Some(line)
178}
179
180pub struct LiveSession {
182 pub id: SessionId,
183 pub name: Option<String>,
185 pub model: String,
186 pub clients: usize,
187}
188
189fn resolve_attach_target(
193 sessions: &[LiveSession],
194 arg: &str,
195) -> std::result::Result<SessionId, String> {
196 if let Some(m) = sessions.iter().find(|m| m.id.as_str() == arg) {
197 return Ok(m.id.clone());
198 }
199 let named: Vec<&LiveSession> = sessions
200 .iter()
201 .filter(|m| m.name.as_deref() == Some(arg))
202 .collect();
203 match named.as_slice() {
204 [one] => return Ok(one.id.clone()),
205 [] => {}
206 many => {
207 let mut lines = vec![format!(
208 "several live sessions are named \"{arg}\"; pick one by id:"
209 )];
210 lines.extend(many.iter().map(|m| format!(" {} model={}", m.id, m.model)));
211 return Err(lines.join("\n"));
212 }
213 }
214 Ok(sessions
215 .iter()
216 .find(|m| m.id.as_str().starts_with(arg))
217 .map(|m| m.id.clone())
218 .unwrap_or_else(|| SessionId::from(arg)))
219}
220
221pub fn choose_attach(
226 opts: &AttachOpts,
227 sessions: &[LiveSession],
228 cwd: Option<PathBuf>,
229 await_extensions: bool,
230) -> std::result::Result<(Attach, Option<String>), String> {
231 let create = |continue_session: Option<Option<String>>| Attach::Create {
232 config: SessionConfig {
233 continue_session,
234 system: opts.system.clone(),
235 prompt_manifest: opts.prompt_manifest.clone(),
236 cwd: cwd.clone(),
237 env: None,
238 compaction_policy: CompactionPolicyWire::LinkedSuccessor,
239 await_extensions,
240 keep_warm: opts.keep_warm,
241 name: opts.name.clone(),
242 ..Default::default()
243 },
244 mode: opts.mode,
245 };
246 if opts.new_session && opts.id.is_some() {
247 return Err("cannot combine --attach <ID> with --new: pick one".to_string());
248 }
249 if let Some(c) = &opts.continue_session {
250 return Ok((create(Some(c.clone())), None));
251 }
252 if opts.new_session {
253 return Ok((create(None), None));
254 }
255 if let Some(id) = &opts.id {
256 let sid = resolve_attach_target(sessions, id)?;
257 return Ok((
258 Attach::Existing {
259 session_id: sid,
260 mode: opts.mode,
261 },
262 None,
263 ));
264 }
265 match sessions {
266 [] => Ok((create(None), None)),
267 [only] => Ok((
268 Attach::Existing {
269 session_id: only.id.clone(),
270 mode: opts.mode,
271 },
272 Some(format!(
273 "attaching to live session {} (--new for a fresh one; `synaps daemon sessions` lists all)",
274 only.id
275 )),
276 )),
277 many => {
278 let mut lines =
279 vec!["several sessions; pick one with --attach <ID or NAME> (or --new for a fresh one):".to_string()];
280 for m in many {
281 let name = m.name.as_deref().map(|n| format!("\"{n}\" ")).unwrap_or_default();
282 lines.push(format!(" {} {name}model={} clients={}", m.id, m.model, m.clients));
283 }
284 Err(lines.join("\n"))
285 }
286 }
287}
288
289pub fn attach_mode(observe: bool, takeover: bool) -> AttachMode {
291 if takeover {
292 return AttachMode::Takeover;
293 }
294 if observe {
295 return AttachMode::Observe;
296 }
297 match std::env::var("SYNAPS_ATTACH_MODE").ok().as_deref() {
298 Some("observe") => AttachMode::Observe,
299 Some("takeover") => AttachMode::Takeover,
300 _ => AttachMode::Mirror,
301 }
302}
303
304pub(super) fn skew_notice_once(
308 app: &mut super::app::App,
309 daemon: agent_engine::session::transport::BinaryId,
310 session: &str,
311) -> Option<String> {
312 use agent_engine::session::transport::{version_skew_notice, BinaryId};
313 if app.skew_noticed.as_ref() == Some(&daemon) {
314 return None;
315 }
316 let notice = version_skew_notice(&BinaryId::this(), &daemon, session)?;
317 app.skew_noticed = Some(daemon);
318 Some(notice)
319}
320
321pub(super) fn session_ended_line(reason: EndReason, session: &str, has_history: bool) -> Option<String> {
329 let what = match reason {
330 EndReason::ClientQuit => "was closed",
331 EndReason::HostShutdown => "ended: the daemon shut down",
332 EndReason::Evicted => "was evicted from the daemon",
333 EndReason::Error => "ended after an error",
334 EndReason::Idle => return None,
335 };
336 Some(if has_history {
337 format!("synaps: session {session} {what}; resume it with: synaps --attach --continue {session}")
338 } else {
339 format!("synaps: session {session} {what} (it never ran a turn, so there is nothing to resume)")
340 })
341}
342
343fn cfg_err(e: impl std::fmt::Display) -> synaps_cli::RuntimeError {
344 synaps_cli::RuntimeError::Config(e.to_string())
345}
346
347pub fn daemon_not_running_message(profile: Option<&str>, detail: Option<&str>) -> String {
352 not_running_message(profile, detail, agent_engine::daemon::autospawn_enabled())
353}
354
355fn not_running_message(profile: Option<&str>, detail: Option<&str>, autospawn: bool) -> String {
356 let start = match profile {
357 Some(p) => format!("synaps --profile {p} daemon --detach"),
358 None => "synaps daemon --detach".to_string(),
359 };
360 let why = detail.map(|d| format!(" ({d})")).unwrap_or_default();
361 if autospawn {
362 return format!(
363 "no daemon is running{why}; it may have just stopped.\n\
364 re-run `synaps --attach` to start a new one, or start it yourself with: {start}"
365 );
366 }
367 format!(
368 "no daemon is running{why} — nothing to attach to.\n\
369 start one with: {start}\n\
370 or run in the foreground: {}\n\
371 then re-run `synaps --attach` (or unset SYNAPS_DAEMON_AUTOSPAWN=0 to auto-start).",
372 start.replace("--detach", "--foreground")
373 )
374}
375
376pub async fn run_attached(mut opts: AttachOpts) -> Result<()> {
377 ladder("attach:enter", &"");
378 let paths = agent_engine::daemon::registry::daemon_paths(opts.profile.as_deref());
379 if !agent_engine::daemon::registry::is_alive(&paths) {
380 return Err(cfg_err(daemon_not_running_message(
381 opts.profile.as_deref(),
382 None,
383 )));
384 }
385 let client_name = opts.client_name.clone().or_else(agent_engine::session::client_name_from_env);
386 if let Some(name) = client_name.as_deref() {
387 agent_engine::session::validate_client_name(name).map_err(cfg_err)?;
388 }
389 let hello = Hello::new(ClientKind::Tui)
390 .with_name(client_name)
391 .with_history(HistoryMode::from_env_or(HistoryMode::attach_client_default()));
392 let conn = match SocketTransport::connect(&paths.sock, hello).await {
393 Ok(c) => c,
394 Err(TransportError::Version { client, daemon }) => {
395 return Err(cfg_err(format!(
396 "protocol version mismatch (client {client}, daemon {daemon}); restart or reload the daemon with this binary"
397 )))
398 }
399 Err(TransportError::Io(e)) => {
400 return Err(cfg_err(daemon_not_running_message(
403 opts.profile.as_deref(),
404 Some(&format!("{}: {e}", paths.sock.display())),
405 )));
406 }
407 Err(e) => return Err(cfg_err(format!("connect: {e}"))),
408 };
409 if let Some(msg) =
410 agent_engine::daemon::profile_mismatch(conn.welcome.profile.as_deref(), opts.profile.as_deref())
411 {
412 return Err(cfg_err(msg));
413 }
414 ladder(
415 "connect",
416 &format_args!("sessions={}", conn.welcome.sessions.len()),
417 );
418
419 let cwd = std::env::current_dir().ok();
420
421 if let Some(ref cwd_path) = cwd {
424 let mut probe_cfg = SessionConfig {
425 system: opts.system.clone(),
426 prompt_manifest: opts.prompt_manifest.clone(),
427 ..Default::default()
428 };
429 if let Err(e) = agent_engine::session::client_args::resolve_client_session_args(
430 &mut probe_cfg,
431 cwd_path,
432 ) {
433 eprintln!("synaps: {e}");
435 return Err(cfg_err(e));
436 }
437 opts.system = probe_cfg.system;
438 opts.prompt_manifest = probe_cfg.prompt_manifest;
439 }
440
441 let live: Vec<LiveSession> = conn
442 .welcome
443 .sessions
444 .iter()
445 .map(|m| LiveSession {
446 id: m.id.clone(),
447 name: m.name.clone(),
448 model: m.model.clone(),
449 clients: m.clients,
450 })
451 .collect();
452 let welcome_sessions = conn.welcome.sessions.clone();
453 let await_extensions = !synaps_cli::load_config().startup.quick_start;
457 let (attach, notice) =
458 choose_attach(&opts, &live, cwd, await_extensions).map_err(cfg_err)?;
459 if let Some(n) = notice {
460 eprintln!("{n}");
462 }
463
464 let (transport, snapshot) = SocketTransport::attach(conn, attach)
465 .await
466 .map_err(|e| cfg_err(format!("attach: {e}")))?;
467 ladder(
470 "attached",
471 &format_args!(
472 "frame_bytes=n/a messages_len={} api_messages={} replay={} tail_items={}",
473 snapshot.conversation.messages_len,
474 snapshot.conversation.api_messages.len(),
475 snapshot.replay.len(),
476 agent_engine::session::DEFAULT_TAIL_ITEMS
477 ),
478 );
479 super::client_diet::purge_arenas("purge:attached");
481
482 let config = synaps_cli::load_config();
484 let registry = Arc::new(CommandRegistry::new(BUILTIN_COMMANDS, Vec::new()));
485 let mut keybinds = synaps_cli::skills::keybinds::KeybindRegistry::new();
486 if !config.keybinds.is_empty() {
487 keybinds.register_user(&config.keybinds);
488 }
489 let keybind_registry = Arc::new(std::sync::RwLock::new(keybinds));
490 let system_prompt_path = synaps_cli::config::resolve_read_path("system.md");
491 ladder("config", &"");
492 let http = LazyHttp::new();
493 if std::env::var("SYNAPS_CLIENT_HTTP").is_ok_and(|v| v == "eager") {
494 http.get()?;
495 }
496
497 let mut app = app_from_snapshot(&snapshot);
498 app.observing = transport.mode() == AttachMode::Observe;
499 app.attached_clients = transport.roster().clients().len();
500 let (msgs, bytes) = super::app::scrollback_from_env(&TransportMode::Socket);
501 app.transcript.set_scrollback(msgs, bytes);
502 ladder("app", &"");
503 for n in super::run_setup::take_boot_notices() {
505 app.push_msg(ChatMessage::System(n));
506 }
507 let notices = attach_notices(AttachNoticeInputs {
508 session_id: &snapshot.meta.id,
509 client_id: transport.client_id().0,
510 mode: transport.mode(),
511 owned_elsewhere: snapshot.input_owned_elsewhere(transport.client_id()),
512 adopted: opts.adopted.then_some(opts.continue_session.is_some()),
513 others_banner: if opts.adopted {
514 adopt_banner(&welcome_sessions, &snapshot.meta.id, chrono::Utc::now())
515 } else {
516 None
517 },
518 config_warnings: &config.warnings,
519 version_skew: skew_notice_once(
520 &mut app,
521 agent_engine::session::transport::BinaryId::of(&transport.welcome),
522 snapshot.meta.id.as_str(),
523 ),
524 });
525 for n in notices.transcript {
526 app.push_msg(ChatMessage::System(n));
527 }
528 if let Some(n) = super::run_setup::no_credentials_notice() {
532 app.push_msg(ChatMessage::System(n));
533 }
534 if !notices.toast.is_empty() {
535 app.toasts.upsert(
536 super::toast::Toast::new(ATTACH_TOAST_ID, String::new())
537 .titled("daemon")
538 .lines(notices.toast),
539 );
540 }
541 let replay = snapshot.replay.clone();
544 let pending = snapshot.pending_prompts.clone();
545 let owns_input = super::session_link::may_drive(&transport);
546 app.streaming = snapshot.streaming;
547
548 let mut ctx = finish_setup(
549 app,
550 Box::new(transport),
551 http,
552 TransportMode::Socket,
553 config,
554 registry,
555 keybind_registry,
556 system_prompt_path,
557 None,
558 )
559 .await?;
560 ctx.app.extension_loader_running = false;
562 ctx.prompt_bridge.set_owns_input(owns_input);
566 if opts.no_confirm_answers || opts.mode == AttachMode::Observe {
567 ctx.prompt_bridge
568 .set_confirm_policy(super::session_link::ConfirmPolicy::ViewOnly);
569 }
570 for env in replay {
571 let _ = super::stream_handler::handle_session_event_arm(
573 env,
574 &mut ctx.app,
575 &mut ctx.link,
576 &ctx.registry,
577 &ctx.render_handle,
578 &mut ctx.prompt_bridge,
579 None,
580 )
581 .await;
582 }
583 ctx.app.flush_reveal();
585 for pr in pending {
586 ctx.prompt_bridge.on_prompt(pr);
587 }
588 super::stream_handler::sync_waiting_confirms(&mut ctx.app, &ctx.prompt_bridge);
589 ladder("replay", &"");
590 if opts.keep_warm {
591 let _ = ctx.link.send(SessionCommand::KeepWarm { on: true }).await;
592 }
593 super::run_loop(ctx).await
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[test]
604 fn an_ended_session_says_how_to_continue_it() {
605 let line = session_ended_line(EndReason::ClientQuit, "20261014-183459-3f61", true).unwrap();
606 assert_eq!(
607 line,
608 "synaps: session 20261014-183459-3f61 was closed; resume it with: \
609 synaps --attach --continue 20261014-183459-3f61"
610 );
611 let stop = session_ended_line(EndReason::HostShutdown, "s1", true).unwrap();
612 assert!(stop.contains("the daemon shut down") && stop.ends_with("--continue s1"), "{stop}");
613 assert!(session_ended_line(EndReason::Evicted, "s1", true).unwrap().contains("evicted"));
614 assert!(session_ended_line(EndReason::Error, "s1", true).unwrap().contains("error"));
615 let empty = session_ended_line(EndReason::ClientQuit, "s1", false).unwrap();
616 assert_eq!(empty, "synaps: session s1 was closed (it never ran a turn, so there is nothing to resume)");
617 assert_eq!(session_ended_line(EndReason::Idle, "s1", true), None);
618 }
619
620 fn notice_inputs<'a>(id: &'a SessionId, warnings: &'a [String]) -> AttachNoticeInputs<'a> {
621 AttachNoticeInputs {
622 session_id: id,
623 client_id: 1,
624 mode: AttachMode::Mirror,
625 owned_elsewhere: None,
626 adopted: None,
627 others_banner: None,
628 config_warnings: warnings,
629 version_skew: None,
630 }
631 }
632
633 #[test]
634 fn routine_attach_notes_go_to_the_toast_so_the_logo_shows() {
635 let id = SessionId::from("20261001-000000-ours".to_string());
636 let n = attach_notices(AttachNoticeInputs { adopted: Some(false), ..notice_inputs(&id, &[]) });
638 assert!(n.transcript.is_empty(), "a fresh session's transcript stays empty: {n:?}");
639 assert_eq!(n.toast[0], "attached to 20261001-000000-ours as client #1 (Mirror)");
640 assert_eq!(n.toast[1], "adopted the running daemon (fresh session)");
641 assert!(n.toast[2].contains("SYNAPS_DAEMON_ADOPT=0"), "{n:?}");
642 let n = attach_notices(AttachNoticeInputs { adopted: Some(true), ..notice_inputs(&id, &[]) });
644 assert_eq!(n.toast[1], "adopted the running daemon (continued session)");
645 let n = attach_notices(notice_inputs(&id, &[]));
647 assert_eq!(n, AttachNotices { transcript: vec![], toast: vec![
648 "attached to 20261001-000000-ours as client #1 (Mirror)".to_string(),
649 ] });
650 }
651
652 #[test]
653 fn notes_that_need_the_user_stay_in_the_transcript() {
654 let id = SessionId::from("20261001-000000-ours".to_string());
655 let warnings = vec!["unknown key `fooo`".to_string()];
656 let n = attach_notices(AttachNoticeInputs {
657 adopted: Some(false),
658 others_banner: Some("2 other sessions in this daemon".to_string()),
659 owned_elsewhere: Some("input is owned by client #3 (tui)".to_string()),
660 ..notice_inputs(&id, &warnings)
661 });
662 assert_eq!(n.transcript, vec![
663 "2 other sessions in this daemon".to_string(),
664 "⚠ config: unknown key `fooo`".to_string(),
665 "attached to 20261001-000000-ours as client #1 (Mirror) — input is owned by client #3 (tui), or /takeover here"
666 .to_string(),
667 ]);
668 assert!(n.toast.iter().all(|l| !l.starts_with("attached to")), "{n:?}");
670 assert_eq!(n.toast[0], "adopted the running daemon (fresh session)");
671 }
672
673 #[test]
676 fn an_observer_is_not_offered_takeover() {
677 let id = SessionId::from("20261001-000000-ours".to_string());
678 let n = attach_notices(AttachNoticeInputs {
679 mode: AttachMode::Observe,
680 owned_elsewhere: Some("input is owned by client #3 (tui)".to_string()),
681 ..notice_inputs(&id, &[])
682 });
683 assert_eq!(n.transcript, vec![
684 "attached to 20261001-000000-ours as client #1 (Observe) — input is owned by client #3 (tui)"
685 .to_string(),
686 ]);
687 }
688
689 #[test]
690 fn a_client_older_than_the_daemon_says_so_in_the_transcript() {
691 let id = SessionId::from("20261001-000000-ours".to_string());
692 use agent_engine::session::transport::{version_skew_notice, BinaryId};
693 let bin = |version: &str| BinaryId { version: version.into(), build: String::new() };
694 let skew = version_skew_notice(&bin("0.10.1"), &bin("0.10.2"), id.as_str());
695 let n = attach_notices(AttachNoticeInputs { version_skew: skew, ..notice_inputs(&id, &[]) });
696 assert_eq!(n.transcript.len(), 1, "{n:?}");
697 assert!(n.transcript[0].starts_with("⚠ this client is synaps 0.10.1 but the daemon is 0.10.2"), "{n:?}");
698 assert!(n.transcript[0].contains("synaps --attach 20261001-000000-ours"), "{n:?}");
699 let same = version_skew_notice(&bin("0.10.2"), &bin("0.10.2"), id.as_str());
701 let n = attach_notices(AttachNoticeInputs { version_skew: same, ..notice_inputs(&id, &[]) });
702 assert!(n.transcript.is_empty(), "{n:?}");
703 }
704
705 #[test]
708 fn the_skew_notice_shows_once_per_daemon_build() {
709 use agent_engine::session::transport::BinaryId;
710 let mut app = crate::tui::app::App::new(synaps_cli::Session::new("test-model", "low", None));
711 let daemon = |build: &str| BinaryId { version: "0.0.1".into(), build: build.into() };
712 assert!(skew_notice_once(&mut app, daemon("aaaa1111"), "s").is_some());
713 assert!(
714 skew_notice_once(&mut app, daemon("aaaa1111"), "s").is_none(),
715 "a reconnect to the same daemon build says nothing again"
716 );
717 assert!(
718 skew_notice_once(&mut app, daemon("bbbb2222"), "s").is_some(),
719 "a reload onto another daemon build warns again"
720 );
721 }
722
723 #[test]
724 fn not_running_message_says_so_and_how_to_start() {
725 let m = not_running_message(None, None, false);
726 assert!(m.starts_with("no daemon is running"), "{m}");
727 assert!(m.contains("synaps daemon --detach"), "{m}");
728 assert!(m.contains("synaps daemon --foreground"), "{m}");
729 assert!(m.contains("SYNAPS_DAEMON_AUTOSPAWN=0"), "{m}");
730 let m = not_running_message(Some("work"), Some("connection refused"), false);
731 assert!(m.contains("(connection refused)"), "{m}");
732 assert!(m.contains("synaps --profile work daemon --detach"), "{m}");
733 }
734
735 #[test]
736 fn not_running_message_skips_the_autospawn_hint_when_autospawn_is_on() {
737 let m = not_running_message(Some("demo"), Some("connection refused"), true);
738 assert!(m.starts_with("no daemon is running (connection refused)"), "{m}");
739 assert!(m.contains("re-run `synaps --attach`"), "{m}");
740 assert!(m.contains("synaps --profile demo daemon --detach"), "{m}");
741 assert!(!m.contains("SYNAPS_DAEMON_AUTOSPAWN"), "{m}");
742 }
743
744 fn opts(id: Option<&str>, new_session: bool) -> AttachOpts {
745 AttachOpts {
746 profile: None,
747 id: id.map(str::to_string),
748 continue_session: None,
749 system: None,
750 prompt_manifest: None,
751 mode: AttachMode::Mirror,
752 no_confirm_answers: false,
753 keep_warm: false,
754 new_session,
755 name: None,
756 client_name: None,
757 adopted: false,
758 }
759 }
760
761 fn meta(id: &str, lifecycle: agent_engine::session::SessionLifecycle, clients: usize, mins_ago: i64) -> agent_engine::session::SessionMeta {
762 agent_engine::session::SessionMeta {
763 id: SessionId::from(id.to_string()),
764 name: None,
765 model: "m".into(),
766 cwd: Some(std::path::PathBuf::from("/w")),
767 created_at: chrono::Utc::now() - chrono::Duration::minutes(mins_ago),
768 continued: false,
769 continue_info: None,
770 host_pid: 1,
771 lifecycle,
772 clients,
773 input_owner: None,
774 awaiting_input: 0,
775 roster: Vec::new(),
776 client_input: None,
777 journal_id: String::new(),
778 locked_by: None,
779 pinned: false,
780 busy: false,
781 state_since: None,
782 park_due: None,
783 evict_due: None,
784 title: None,
785 cost_usd: 0.0,
786 messages: 0,
787 }
788 }
789
790 #[test]
791 fn await_extensions_flows_from_quick_start() {
792 let (a, _) = choose_attach(&opts(None, true), &[], None, true).unwrap();
795 match a {
796 Attach::Create { config, .. } => assert!(config.await_extensions),
797 other => panic!("expected Create, got {other:?}"),
798 }
799 let (a, _) = choose_attach(&opts(None, true), &[], None, false).unwrap();
801 match a {
802 Attach::Create { config, .. } => assert!(!config.await_extensions),
803 other => panic!("expected Create, got {other:?}"),
804 }
805 }
806
807 #[test]
808 fn bare_continue_means_most_recent_not_fresh() {
809 let mut o = opts(None, true);
813 o.continue_session = Some(None);
814 let (a, _) = choose_attach(&o, &[live("abc")], None, true).unwrap();
815 match a {
816 Attach::Create { config, .. } => assert_eq!(config.continue_session, Some(None)),
817 other => panic!("expected Create, got {other:?}"),
818 }
819 let mut o = opts(None, false);
820 o.continue_session = Some(Some("xyz".into()));
821 let (a, _) = choose_attach(&o, &[], None, true).unwrap();
822 match a {
823 Attach::Create { config, .. } => assert_eq!(config.continue_session, Some(Some("xyz".into()))),
824 other => panic!("expected Create, got {other:?}"),
825 }
826 }
827
828 #[test]
829 fn adopt_banner_lists_other_sessions_only() {
830 use agent_engine::session::SessionLifecycle as L;
831 let ours = SessionId::from("20260919-000000-ours".to_string());
832 let now = chrono::Utc::now();
833 assert!(adopt_banner(&[meta("20260919-000000-ours", L::Live, 1, 0)], &ours, now).is_none());
835 let sessions = vec![
836 meta("20260919-000000-ours", L::Live, 1, 0),
837 meta("20260919-010000-aaaa", L::Parked, 0, 125),
838 meta("20260919-020000-bbbb", L::Live, 1, 3),
839 ];
840 let b = adopt_banner(&sessions, &ours, now).unwrap();
841 assert!(b.starts_with("2 other sessions in this daemon (1 live, 1 parked)"), "{b}");
842 assert!(!b.contains("ours"), "{b}");
843 let rows: Vec<&str> = b.lines().skip(1).collect();
845 assert!(rows[0].contains("\u{2026}bbbb") && rows[0].contains("attached") && rows[0].contains("/w"), "{b}");
846 assert!(rows[1].contains("\u{2026}aaaa") && rows[1].contains("parked"), "{b}");
847 let many: Vec<_> = (0..8).map(|i| meta(&format!("20260919-00000{i}-xxxx"), L::Parked, 0, i)).collect();
849 let b = adopt_banner(&many, &ours, now).unwrap();
850 assert_eq!(b.lines().count(), 1 + 5 + 1, "{b}");
851 assert!(b.contains("and 3 more"), "{b}");
852 }
853
854 #[test]
858 fn adopt_banner_matches_the_sessions_modal() {
859 use agent_engine::session::SessionLifecycle as L;
860 let ours = SessionId::from("20260919-000000-ours".to_string());
861 let now = chrono::Utc::now();
862 let home = std::env::temp_dir();
864 let mut m = meta("20260919-010000-793d", L::Parked, 0, 4);
865 m.name = Some("gallery2".into());
866 m.state_since = Some(now - chrono::Duration::minutes(2));
867 m.evict_due = Some(now + chrono::Duration::minutes(57) + chrono::Duration::seconds(30));
868 m.cwd = Some(home.join("Projects/synaps"));
869 let b = adopt_banner_with_home(
870 &[meta("20260919-000000-ours", L::Live, 1, 0), m.clone()],
871 &ours,
872 now,
873 Some(&home),
874 )
875 .unwrap();
876 assert_eq!(
877 b,
878 format!(
879 "1 other session in this daemon (1 parked) \u{2014} /sessions to switch:\n gallery2 {} ~/Projects/synaps",
880 crate::tui::sessions::state_text(&m, now)
881 )
882 );
883 assert!(b.contains("parked 2m"), "{b}");
884 assert!(!b.contains('`') && !b.contains("ago"), "{b}");
885 }
886
887 fn live(id: &str) -> LiveSession {
888 LiveSession {
889 id: SessionId::from(id),
890 name: None,
891 model: "m".into(),
892 clients: 1,
893 }
894 }
895
896 #[test]
897 fn new_creates_even_with_live_sessions() {
898 let (a, notice) = choose_attach(&opts(None, true), &[live("abc")], None, true).unwrap();
899 assert!(matches!(a, Attach::Create { .. }), "{a:?}");
900 assert!(notice.is_none());
901 let (a, _) = choose_attach(&opts(None, true), &[live("a"), live("b")], None, true).unwrap();
902 assert!(matches!(a, Attach::Create { .. }), "{a:?}");
903 }
904
905 fn named(id: &str, name: &str) -> LiveSession {
906 LiveSession { name: Some(name.into()), ..live(id) }
907 }
908
909 #[test]
910 fn attach_resolves_a_session_name() {
911 let sessions = [live("20261026-081319-a97a"), named("20261026-081500-793d", "pair")];
912 let (a, _) = choose_attach(&opts(Some("pair"), false), &sessions, None, true).unwrap();
913 assert!(
914 matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "20261026-081500-793d"),
915 "{a:?}"
916 );
917 let sessions = [named("abc", "x"), named("def", "abc")];
919 let (a, _) = choose_attach(&opts(Some("abc"), false), &sessions, None, true).unwrap();
920 assert!(matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "abc"), "{a:?}");
921 let (a, _) = choose_attach(&opts(Some("de"), false), &sessions, None, true).unwrap();
922 assert!(matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "def"), "{a:?}");
923 }
924
925 #[test]
926 fn attach_by_ambiguous_name_lists_the_ids() {
927 let sessions = [named("aaa", "pair"), named("bbb", "pair")];
928 let e = choose_attach(&opts(Some("pair"), false), &sessions, None, true).unwrap_err();
929 assert!(e.contains("aaa") && e.contains("bbb") && e.contains("by id"), "{e}");
930 }
931
932 #[test]
933 fn new_with_explicit_id_is_rejected() {
934 let e = choose_attach(&opts(Some("abc"), true), &[live("abc")], None, true).unwrap_err();
935 assert!(e.contains("cannot combine"), "{e}");
936 }
937
938 #[test]
939 fn sole_live_session_is_picked_with_notice() {
940 let (a, notice) = choose_attach(&opts(None, false), &[live("abc")], None, true).unwrap();
941 assert!(
942 matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "abc"),
943 "{a:?}"
944 );
945 let n = notice.unwrap();
946 assert!(n.contains("abc") && n.contains("--new") && n.contains("daemon sessions"), "{n}");
947 }
948
949 #[test]
950 fn no_live_sessions_creates_and_many_errors_with_hint() {
951 let (a, _) = choose_attach(&opts(None, false), &[], None, true).unwrap();
952 assert!(matches!(a, Attach::Create { .. }), "{a:?}");
953 let e = choose_attach(&opts(None, false), &[live("a"), live("b")], None, true).unwrap_err();
954 assert!(e.contains("--new") && e.contains(" a ") && e.contains(" b "), "{e}");
955 }
956
957 #[test]
958 fn attach_mode_flags_override_env() {
959 assert_eq!(attach_mode(false, true), AttachMode::Takeover);
960 assert_eq!(attach_mode(true, false), AttachMode::Observe);
961 }
962}