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 owned: None,
246 };
247 if opts.new_session && opts.id.is_some() {
248 return Err("cannot combine --attach <ID> with --new: pick one".to_string());
249 }
250 if let Some(c) = &opts.continue_session {
251 return Ok((create(Some(c.clone())), None));
252 }
253 if opts.new_session {
254 return Ok((create(None), None));
255 }
256 if let Some(id) = &opts.id {
257 let sid = resolve_attach_target(sessions, id)?;
258 return Ok((
259 Attach::Existing {
260 session_id: sid,
261 mode: opts.mode,
262 },
263 None,
264 ));
265 }
266 match sessions {
267 [] => Ok((create(None), None)),
268 [only] => Ok((
269 Attach::Existing {
270 session_id: only.id.clone(),
271 mode: opts.mode,
272 },
273 Some(format!(
274 "attaching to live session {} (--new for a fresh one; `synaps daemon sessions` lists all)",
275 only.id
276 )),
277 )),
278 many => {
279 let mut lines =
280 vec!["several sessions; pick one with --attach <ID or NAME> (or --new for a fresh one):".to_string()];
281 for m in many {
282 let name = m.name.as_deref().map(|n| format!("\"{n}\" ")).unwrap_or_default();
283 lines.push(format!(" {} {name}model={} clients={}", m.id, m.model, m.clients));
284 }
285 Err(lines.join("\n"))
286 }
287 }
288}
289
290pub fn attach_mode(observe: bool, takeover: bool) -> AttachMode {
292 if takeover {
293 return AttachMode::Takeover;
294 }
295 if observe {
296 return AttachMode::Observe;
297 }
298 match std::env::var("SYNAPS_ATTACH_MODE").ok().as_deref() {
299 Some("observe") => AttachMode::Observe,
300 Some("takeover") => AttachMode::Takeover,
301 _ => AttachMode::Mirror,
302 }
303}
304
305pub(super) fn skew_notice_once(
309 app: &mut super::app::App,
310 daemon: agent_engine::session::transport::BinaryId,
311 session: &str,
312) -> Option<String> {
313 use agent_engine::session::transport::{version_skew_notice, BinaryId};
314 if app.skew_noticed.as_ref() == Some(&daemon) {
315 return None;
316 }
317 let notice = version_skew_notice(&BinaryId::this(), &daemon, session)?;
318 app.skew_noticed = Some(daemon);
319 Some(notice)
320}
321
322pub(super) fn session_ended_line(reason: EndReason, session: &str, has_history: bool) -> Option<String> {
330 let what = match reason {
331 EndReason::ClientQuit => "was closed",
332 EndReason::OwnerGone => "ended: the client that owned it disconnected",
333 EndReason::HostShutdown => "ended: the daemon shut down",
334 EndReason::Evicted => "was evicted from the daemon",
335 EndReason::Error => "ended after an error",
336 EndReason::Idle => return None,
337 };
338 Some(if has_history {
339 format!("synaps: session {session} {what}; resume it with: synaps --attach --continue {session}")
340 } else {
341 format!("synaps: session {session} {what} (it never ran a turn, so there is nothing to resume)")
342 })
343}
344
345fn cfg_err(e: impl std::fmt::Display) -> synaps_cli::RuntimeError {
346 synaps_cli::RuntimeError::Config(e.to_string())
347}
348
349pub fn daemon_not_running_message(profile: Option<&str>, detail: Option<&str>) -> String {
354 not_running_message(profile, detail, agent_engine::daemon::autospawn_enabled())
355}
356
357fn not_running_message(profile: Option<&str>, detail: Option<&str>, autospawn: bool) -> String {
358 let start = match profile {
359 Some(p) => format!("synaps --profile {p} daemon --detach"),
360 None => "synaps daemon --detach".to_string(),
361 };
362 let why = detail.map(|d| format!(" ({d})")).unwrap_or_default();
363 if autospawn {
364 return format!(
365 "no daemon is running{why}; it may have just stopped.\n\
366 re-run `synaps --attach` to start a new one, or start it yourself with: {start}"
367 );
368 }
369 format!(
370 "no daemon is running{why} — nothing to attach to.\n\
371 start one with: {start}\n\
372 or run in the foreground: {}\n\
373 then re-run `synaps --attach` (or unset SYNAPS_DAEMON_AUTOSPAWN=0 to auto-start).",
374 start.replace("--detach", "--foreground")
375 )
376}
377
378pub async fn run_attached(mut opts: AttachOpts) -> Result<()> {
379 ladder("attach:enter", &"");
380 let paths = agent_engine::daemon::registry::daemon_paths(opts.profile.as_deref());
381 if !agent_engine::daemon::registry::is_alive(&paths) {
382 return Err(cfg_err(daemon_not_running_message(
383 opts.profile.as_deref(),
384 None,
385 )));
386 }
387 let client_name = opts.client_name.clone().or_else(agent_engine::session::client_name_from_env);
388 if let Some(name) = client_name.as_deref() {
389 agent_engine::session::validate_client_name(name).map_err(cfg_err)?;
390 }
391 let hello = Hello::new(ClientKind::Tui)
392 .with_name(client_name)
393 .with_history(HistoryMode::from_env_or(HistoryMode::attach_client_default()));
394 let conn = match SocketTransport::connect(&paths.sock, hello).await {
395 Ok(c) => c,
396 Err(TransportError::Version { client, daemon }) => {
397 return Err(cfg_err(format!(
398 "protocol version mismatch (client {client}, daemon {daemon}); restart or reload the daemon with this binary"
399 )))
400 }
401 Err(TransportError::Io(e)) => {
402 return Err(cfg_err(daemon_not_running_message(
405 opts.profile.as_deref(),
406 Some(&format!("{}: {e}", paths.sock.display())),
407 )));
408 }
409 Err(e) => return Err(cfg_err(format!("connect: {e}"))),
410 };
411 if let Some(msg) =
412 agent_engine::daemon::profile_mismatch(conn.welcome.profile.as_deref(), opts.profile.as_deref())
413 {
414 return Err(cfg_err(msg));
415 }
416 ladder(
417 "connect",
418 &format_args!("sessions={}", conn.welcome.sessions.len()),
419 );
420
421 let cwd = std::env::current_dir().ok();
422
423 if let Some(ref cwd_path) = cwd {
426 let mut probe_cfg = SessionConfig {
427 system: opts.system.clone(),
428 prompt_manifest: opts.prompt_manifest.clone(),
429 ..Default::default()
430 };
431 if let Err(e) = agent_engine::session::client_args::resolve_client_session_args(
432 &mut probe_cfg,
433 cwd_path,
434 ) {
435 eprintln!("synaps: {e}");
437 return Err(cfg_err(e));
438 }
439 opts.system = probe_cfg.system;
440 opts.prompt_manifest = probe_cfg.prompt_manifest;
441 }
442
443 let live: Vec<LiveSession> = conn
444 .welcome
445 .sessions
446 .iter()
447 .map(|m| LiveSession {
448 id: m.id.clone(),
449 name: m.name.clone(),
450 model: m.model.clone(),
451 clients: m.clients,
452 })
453 .collect();
454 let welcome_sessions = conn.welcome.sessions.clone();
455 let await_extensions = !synaps_cli::load_config().startup.quick_start;
459 let (attach, notice) =
460 choose_attach(&opts, &live, cwd, await_extensions).map_err(cfg_err)?;
461 if let Some(n) = notice {
462 eprintln!("{n}");
464 }
465
466 let (transport, snapshot) = SocketTransport::attach(conn, attach)
467 .await
468 .map_err(|e| cfg_err(format!("attach: {e}")))?;
469 ladder(
472 "attached",
473 &format_args!(
474 "frame_bytes=n/a messages_len={} api_messages={} replay={} tail_items={}",
475 snapshot.conversation.messages_len,
476 snapshot.conversation.api_messages.len(),
477 snapshot.replay.len(),
478 agent_engine::session::DEFAULT_TAIL_ITEMS
479 ),
480 );
481 super::client_diet::purge_arenas("purge:attached");
483
484 let config = synaps_cli::load_config();
486 let registry = Arc::new(CommandRegistry::new(BUILTIN_COMMANDS, Vec::new()));
487 let mut keybinds = synaps_cli::skills::keybinds::KeybindRegistry::new();
488 if !config.keybinds.is_empty() {
489 keybinds.register_user(&config.keybinds);
490 }
491 let keybind_registry = Arc::new(std::sync::RwLock::new(keybinds));
492 let system_prompt_path = synaps_cli::config::resolve_read_path("system.md");
493 ladder("config", &"");
494 let http = LazyHttp::new();
495 if std::env::var("SYNAPS_CLIENT_HTTP").is_ok_and(|v| v == "eager") {
496 http.get()?;
497 }
498
499 let mut app = app_from_snapshot(&snapshot);
500 app.observing = transport.mode() == AttachMode::Observe;
501 app.attached_clients = transport.roster().clients().len();
502 let (msgs, bytes) = super::app::scrollback_from_env(&TransportMode::Socket);
503 app.transcript.set_scrollback(msgs, bytes);
504 ladder("app", &"");
505 for n in super::run_setup::take_boot_notices() {
507 app.push_msg(ChatMessage::System(n));
508 }
509 let notices = attach_notices(AttachNoticeInputs {
510 session_id: &snapshot.meta.id,
511 client_id: transport.client_id().0,
512 mode: transport.mode(),
513 owned_elsewhere: snapshot.input_owned_elsewhere(transport.client_id()),
514 adopted: opts.adopted.then_some(opts.continue_session.is_some()),
515 others_banner: if opts.adopted {
516 adopt_banner(&welcome_sessions, &snapshot.meta.id, chrono::Utc::now())
517 } else {
518 None
519 },
520 config_warnings: &config.warnings,
521 version_skew: skew_notice_once(
522 &mut app,
523 agent_engine::session::transport::BinaryId::of(&transport.welcome),
524 snapshot.meta.id.as_str(),
525 ),
526 });
527 for n in notices.transcript {
528 app.push_msg(ChatMessage::System(n));
529 }
530 if let Some(n) = super::run_setup::no_credentials_notice() {
534 app.push_msg(ChatMessage::System(n));
535 }
536 if !notices.toast.is_empty() {
537 app.toasts.upsert(
538 super::toast::Toast::new(ATTACH_TOAST_ID, String::new())
539 .titled("daemon")
540 .lines(notices.toast),
541 );
542 }
543 let replay = snapshot.replay.clone();
546 let pending = snapshot.pending_prompts.clone();
547 let owns_input = super::session_link::may_drive(&transport);
548 app.streaming = snapshot.streaming;
549
550 let mut ctx = finish_setup(
551 app,
552 Box::new(transport),
553 http,
554 TransportMode::Socket,
555 config,
556 registry,
557 keybind_registry,
558 system_prompt_path,
559 None,
560 )
561 .await?;
562 ctx.app.extension_loader_running = false;
564 ctx.prompt_bridge.set_owns_input(owns_input);
568 if opts.no_confirm_answers || opts.mode == AttachMode::Observe {
569 ctx.prompt_bridge
570 .set_confirm_policy(super::session_link::ConfirmPolicy::ViewOnly);
571 }
572 for env in replay {
573 let _ = super::stream_handler::handle_session_event_arm(
575 env,
576 &mut ctx.app,
577 &mut ctx.link,
578 &ctx.registry,
579 &ctx.render_handle,
580 &mut ctx.prompt_bridge,
581 None,
582 )
583 .await;
584 }
585 ctx.app.flush_reveal();
587 for pr in pending {
588 ctx.prompt_bridge.on_prompt(pr);
589 }
590 super::stream_handler::sync_waiting_confirms(&mut ctx.app, &ctx.prompt_bridge);
591 ladder("replay", &"");
592 if opts.keep_warm {
593 let _ = ctx.link.send(SessionCommand::KeepWarm { on: true }).await;
594 }
595 super::run_loop(ctx).await
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 #[test]
606 fn an_ended_session_says_how_to_continue_it() {
607 let line = session_ended_line(EndReason::ClientQuit, "20261014-183459-3f61", true).unwrap();
608 assert_eq!(
609 line,
610 "synaps: session 20261014-183459-3f61 was closed; resume it with: \
611 synaps --attach --continue 20261014-183459-3f61"
612 );
613 let stop = session_ended_line(EndReason::HostShutdown, "s1", true).unwrap();
614 assert!(stop.contains("the daemon shut down") && stop.ends_with("--continue s1"), "{stop}");
615 assert!(session_ended_line(EndReason::Evicted, "s1", true).unwrap().contains("evicted"));
616 assert!(session_ended_line(EndReason::Error, "s1", true).unwrap().contains("error"));
617 let empty = session_ended_line(EndReason::ClientQuit, "s1", false).unwrap();
618 assert_eq!(empty, "synaps: session s1 was closed (it never ran a turn, so there is nothing to resume)");
619 assert_eq!(session_ended_line(EndReason::Idle, "s1", true), None);
620 }
621
622 fn notice_inputs<'a>(id: &'a SessionId, warnings: &'a [String]) -> AttachNoticeInputs<'a> {
623 AttachNoticeInputs {
624 session_id: id,
625 client_id: 1,
626 mode: AttachMode::Mirror,
627 owned_elsewhere: None,
628 adopted: None,
629 others_banner: None,
630 config_warnings: warnings,
631 version_skew: None,
632 }
633 }
634
635 #[test]
636 fn routine_attach_notes_go_to_the_toast_so_the_logo_shows() {
637 let id = SessionId::from("20261001-000000-ours".to_string());
638 let n = attach_notices(AttachNoticeInputs { adopted: Some(false), ..notice_inputs(&id, &[]) });
640 assert!(n.transcript.is_empty(), "a fresh session's transcript stays empty: {n:?}");
641 assert_eq!(n.toast[0], "attached to 20261001-000000-ours as client #1 (Mirror)");
642 assert_eq!(n.toast[1], "adopted the running daemon (fresh session)");
643 assert!(n.toast[2].contains("SYNAPS_DAEMON_ADOPT=0"), "{n:?}");
644 let n = attach_notices(AttachNoticeInputs { adopted: Some(true), ..notice_inputs(&id, &[]) });
646 assert_eq!(n.toast[1], "adopted the running daemon (continued session)");
647 let n = attach_notices(notice_inputs(&id, &[]));
649 assert_eq!(n, AttachNotices { transcript: vec![], toast: vec![
650 "attached to 20261001-000000-ours as client #1 (Mirror)".to_string(),
651 ] });
652 }
653
654 #[test]
655 fn notes_that_need_the_user_stay_in_the_transcript() {
656 let id = SessionId::from("20261001-000000-ours".to_string());
657 let warnings = vec!["unknown key `fooo`".to_string()];
658 let n = attach_notices(AttachNoticeInputs {
659 adopted: Some(false),
660 others_banner: Some("2 other sessions in this daemon".to_string()),
661 owned_elsewhere: Some("input is owned by client #3 (tui)".to_string()),
662 ..notice_inputs(&id, &warnings)
663 });
664 assert_eq!(n.transcript, vec![
665 "2 other sessions in this daemon".to_string(),
666 "⚠ config: unknown key `fooo`".to_string(),
667 "attached to 20261001-000000-ours as client #1 (Mirror) — input is owned by client #3 (tui), or /takeover here"
668 .to_string(),
669 ]);
670 assert!(n.toast.iter().all(|l| !l.starts_with("attached to")), "{n:?}");
672 assert_eq!(n.toast[0], "adopted the running daemon (fresh session)");
673 }
674
675 #[test]
678 fn an_observer_is_not_offered_takeover() {
679 let id = SessionId::from("20261001-000000-ours".to_string());
680 let n = attach_notices(AttachNoticeInputs {
681 mode: AttachMode::Observe,
682 owned_elsewhere: Some("input is owned by client #3 (tui)".to_string()),
683 ..notice_inputs(&id, &[])
684 });
685 assert_eq!(n.transcript, vec![
686 "attached to 20261001-000000-ours as client #1 (Observe) — input is owned by client #3 (tui)"
687 .to_string(),
688 ]);
689 }
690
691 #[test]
692 fn a_client_older_than_the_daemon_says_so_in_the_transcript() {
693 let id = SessionId::from("20261001-000000-ours".to_string());
694 use agent_engine::session::transport::{version_skew_notice, BinaryId};
695 let bin = |version: &str| BinaryId { version: version.into(), build: String::new() };
696 let skew = version_skew_notice(&bin("0.10.1"), &bin("0.10.2"), id.as_str());
697 let n = attach_notices(AttachNoticeInputs { version_skew: skew, ..notice_inputs(&id, &[]) });
698 assert_eq!(n.transcript.len(), 1, "{n:?}");
699 assert!(n.transcript[0].starts_with("⚠ this client is synaps 0.10.1 but the daemon is 0.10.2"), "{n:?}");
700 assert!(n.transcript[0].contains("synaps --attach 20261001-000000-ours"), "{n:?}");
701 let same = version_skew_notice(&bin("0.10.2"), &bin("0.10.2"), id.as_str());
703 let n = attach_notices(AttachNoticeInputs { version_skew: same, ..notice_inputs(&id, &[]) });
704 assert!(n.transcript.is_empty(), "{n:?}");
705 }
706
707 #[test]
710 fn the_skew_notice_shows_once_per_daemon_build() {
711 use agent_engine::session::transport::BinaryId;
712 let mut app = crate::tui::app::App::new(synaps_cli::Session::new("test-model", "low", None));
713 let daemon = |build: &str| BinaryId { version: "0.0.1".into(), build: build.into() };
714 assert!(skew_notice_once(&mut app, daemon("aaaa1111"), "s").is_some());
715 assert!(
716 skew_notice_once(&mut app, daemon("aaaa1111"), "s").is_none(),
717 "a reconnect to the same daemon build says nothing again"
718 );
719 assert!(
720 skew_notice_once(&mut app, daemon("bbbb2222"), "s").is_some(),
721 "a reload onto another daemon build warns again"
722 );
723 }
724
725 #[test]
726 fn not_running_message_says_so_and_how_to_start() {
727 let m = not_running_message(None, None, false);
728 assert!(m.starts_with("no daemon is running"), "{m}");
729 assert!(m.contains("synaps daemon --detach"), "{m}");
730 assert!(m.contains("synaps daemon --foreground"), "{m}");
731 assert!(m.contains("SYNAPS_DAEMON_AUTOSPAWN=0"), "{m}");
732 let m = not_running_message(Some("work"), Some("connection refused"), false);
733 assert!(m.contains("(connection refused)"), "{m}");
734 assert!(m.contains("synaps --profile work daemon --detach"), "{m}");
735 }
736
737 #[test]
738 fn not_running_message_skips_the_autospawn_hint_when_autospawn_is_on() {
739 let m = not_running_message(Some("demo"), Some("connection refused"), true);
740 assert!(m.starts_with("no daemon is running (connection refused)"), "{m}");
741 assert!(m.contains("re-run `synaps --attach`"), "{m}");
742 assert!(m.contains("synaps --profile demo daemon --detach"), "{m}");
743 assert!(!m.contains("SYNAPS_DAEMON_AUTOSPAWN"), "{m}");
744 }
745
746 fn opts(id: Option<&str>, new_session: bool) -> AttachOpts {
747 AttachOpts {
748 profile: None,
749 id: id.map(str::to_string),
750 continue_session: None,
751 system: None,
752 prompt_manifest: None,
753 mode: AttachMode::Mirror,
754 no_confirm_answers: false,
755 keep_warm: false,
756 new_session,
757 name: None,
758 client_name: None,
759 adopted: false,
760 }
761 }
762
763 fn meta(id: &str, lifecycle: agent_engine::session::SessionLifecycle, clients: usize, mins_ago: i64) -> agent_engine::session::SessionMeta {
764 agent_engine::session::SessionMeta {
765 id: SessionId::from(id.to_string()),
766 name: None,
767 model: "m".into(),
768 cwd: Some(std::path::PathBuf::from("/w")),
769 created_at: chrono::Utc::now() - chrono::Duration::minutes(mins_ago),
770 continued: false,
771 continue_info: None,
772 host_pid: 1,
773 lifecycle,
774 clients,
775 input_owner: None,
776 awaiting_input: 0,
777 roster: Vec::new(),
778 client_input: None,
779 journal_id: String::new(),
780 locked_by: None,
781 pinned: false,
782 busy: false,
783 state_since: None,
784 park_due: None,
785 evict_due: None,
786 title: None,
787 cost_usd: 0.0,
788 messages: 0,
789 }
790 }
791
792 #[test]
793 fn await_extensions_flows_from_quick_start() {
794 let (a, _) = choose_attach(&opts(None, true), &[], None, true).unwrap();
797 match a {
798 Attach::Create { config, .. } => assert!(config.await_extensions),
799 other => panic!("expected Create, got {other:?}"),
800 }
801 let (a, _) = choose_attach(&opts(None, true), &[], None, false).unwrap();
803 match a {
804 Attach::Create { config, .. } => assert!(!config.await_extensions),
805 other => panic!("expected Create, got {other:?}"),
806 }
807 }
808
809 #[test]
810 fn bare_continue_means_most_recent_not_fresh() {
811 let mut o = opts(None, true);
815 o.continue_session = Some(None);
816 let (a, _) = choose_attach(&o, &[live("abc")], None, true).unwrap();
817 match a {
818 Attach::Create { config, .. } => assert_eq!(config.continue_session, Some(None)),
819 other => panic!("expected Create, got {other:?}"),
820 }
821 let mut o = opts(None, false);
822 o.continue_session = Some(Some("xyz".into()));
823 let (a, _) = choose_attach(&o, &[], None, true).unwrap();
824 match a {
825 Attach::Create { config, .. } => assert_eq!(config.continue_session, Some(Some("xyz".into()))),
826 other => panic!("expected Create, got {other:?}"),
827 }
828 }
829
830 #[test]
831 fn adopt_banner_lists_other_sessions_only() {
832 use agent_engine::session::SessionLifecycle as L;
833 let ours = SessionId::from("20260919-000000-ours".to_string());
834 let now = chrono::Utc::now();
835 assert!(adopt_banner(&[meta("20260919-000000-ours", L::Live, 1, 0)], &ours, now).is_none());
837 let sessions = vec![
838 meta("20260919-000000-ours", L::Live, 1, 0),
839 meta("20260919-010000-aaaa", L::Parked, 0, 125),
840 meta("20260919-020000-bbbb", L::Live, 1, 3),
841 ];
842 let b = adopt_banner(&sessions, &ours, now).unwrap();
843 assert!(b.starts_with("2 other sessions in this daemon (1 live, 1 parked)"), "{b}");
844 assert!(!b.contains("ours"), "{b}");
845 let rows: Vec<&str> = b.lines().skip(1).collect();
847 assert!(rows[0].contains("\u{2026}bbbb") && rows[0].contains("attached") && rows[0].contains("/w"), "{b}");
848 assert!(rows[1].contains("\u{2026}aaaa") && rows[1].contains("parked"), "{b}");
849 let many: Vec<_> = (0..8).map(|i| meta(&format!("20260919-00000{i}-xxxx"), L::Parked, 0, i)).collect();
851 let b = adopt_banner(&many, &ours, now).unwrap();
852 assert_eq!(b.lines().count(), 1 + 5 + 1, "{b}");
853 assert!(b.contains("and 3 more"), "{b}");
854 }
855
856 #[test]
860 fn adopt_banner_matches_the_sessions_modal() {
861 use agent_engine::session::SessionLifecycle as L;
862 let ours = SessionId::from("20260919-000000-ours".to_string());
863 let now = chrono::Utc::now();
864 let home = std::env::temp_dir();
866 let mut m = meta("20260919-010000-793d", L::Parked, 0, 4);
867 m.name = Some("gallery2".into());
868 m.state_since = Some(now - chrono::Duration::minutes(2));
869 m.evict_due = Some(now + chrono::Duration::minutes(57) + chrono::Duration::seconds(30));
870 m.cwd = Some(home.join("Projects/synaps"));
871 let b = adopt_banner_with_home(
872 &[meta("20260919-000000-ours", L::Live, 1, 0), m.clone()],
873 &ours,
874 now,
875 Some(&home),
876 )
877 .unwrap();
878 assert_eq!(
879 b,
880 format!(
881 "1 other session in this daemon (1 parked) \u{2014} /sessions to switch:\n gallery2 {} ~/Projects/synaps",
882 crate::tui::sessions::state_text(&m, now)
883 )
884 );
885 assert!(b.contains("parked 2m"), "{b}");
886 assert!(!b.contains('`') && !b.contains("ago"), "{b}");
887 }
888
889 fn live(id: &str) -> LiveSession {
890 LiveSession {
891 id: SessionId::from(id),
892 name: None,
893 model: "m".into(),
894 clients: 1,
895 }
896 }
897
898 #[test]
899 fn new_creates_even_with_live_sessions() {
900 let (a, notice) = choose_attach(&opts(None, true), &[live("abc")], None, true).unwrap();
901 assert!(matches!(a, Attach::Create { .. }), "{a:?}");
902 assert!(notice.is_none());
903 let (a, _) = choose_attach(&opts(None, true), &[live("a"), live("b")], None, true).unwrap();
904 assert!(matches!(a, Attach::Create { .. }), "{a:?}");
905 }
906
907 fn named(id: &str, name: &str) -> LiveSession {
908 LiveSession { name: Some(name.into()), ..live(id) }
909 }
910
911 #[test]
912 fn attach_resolves_a_session_name() {
913 let sessions = [live("20261026-081319-a97a"), named("20261026-081500-793d", "pair")];
914 let (a, _) = choose_attach(&opts(Some("pair"), false), &sessions, None, true).unwrap();
915 assert!(
916 matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "20261026-081500-793d"),
917 "{a:?}"
918 );
919 let sessions = [named("abc", "x"), named("def", "abc")];
921 let (a, _) = choose_attach(&opts(Some("abc"), false), &sessions, None, true).unwrap();
922 assert!(matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "abc"), "{a:?}");
923 let (a, _) = choose_attach(&opts(Some("de"), false), &sessions, None, true).unwrap();
924 assert!(matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "def"), "{a:?}");
925 }
926
927 #[test]
928 fn attach_by_ambiguous_name_lists_the_ids() {
929 let sessions = [named("aaa", "pair"), named("bbb", "pair")];
930 let e = choose_attach(&opts(Some("pair"), false), &sessions, None, true).unwrap_err();
931 assert!(e.contains("aaa") && e.contains("bbb") && e.contains("by id"), "{e}");
932 }
933
934 #[test]
935 fn new_with_explicit_id_is_rejected() {
936 let e = choose_attach(&opts(Some("abc"), true), &[live("abc")], None, true).unwrap_err();
937 assert!(e.contains("cannot combine"), "{e}");
938 }
939
940 #[test]
941 fn sole_live_session_is_picked_with_notice() {
942 let (a, notice) = choose_attach(&opts(None, false), &[live("abc")], None, true).unwrap();
943 assert!(
944 matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "abc"),
945 "{a:?}"
946 );
947 let n = notice.unwrap();
948 assert!(n.contains("abc") && n.contains("--new") && n.contains("daemon sessions"), "{n}");
949 }
950
951 #[test]
952 fn no_live_sessions_creates_and_many_errors_with_hint() {
953 let (a, _) = choose_attach(&opts(None, false), &[], None, true).unwrap();
954 assert!(matches!(a, Attach::Create { .. }), "{a:?}");
955 let e = choose_attach(&opts(None, false), &[live("a"), live("b")], None, true).unwrap_err();
956 assert!(e.contains("--new") && e.contains(" a ") && e.contains(" b "), "{e}");
957 }
958
959 #[test]
960 fn attach_mode_flags_override_env() {
961 assert_eq!(attach_mode(false, true), AttachMode::Takeover);
962 assert_eq!(attach_mode(true, false), AttachMode::Observe);
963 }
964}