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(
127 sessions: &[agent_engine::session::SessionMeta],
128 ours: &SessionId,
129 now: chrono::DateTime<chrono::Utc>,
130) -> Option<String> {
131 use agent_engine::session::SessionLifecycle as L;
132 let mut others: Vec<&agent_engine::session::SessionMeta> =
133 sessions.iter().filter(|m| &m.id != ours).collect();
134 if others.is_empty() {
135 return None;
136 }
137 others.sort_by_key(|m| std::cmp::Reverse(m.created_at));
138 let n = others.len();
139 let parked = others.iter().filter(|m| matches!(m.lifecycle, L::Parked)).count();
140 let live = n - parked;
141 let mut line = format!(
142 "{n} other session{} in this daemon ({live} live, {parked} parked) — `synaps --attach <ID>` joins a live one, `synaps --continue <ID>` resumes a parked one:",
143 if n == 1 { "" } else { "s" }
144 );
145 for m in others.iter().take(5) {
146 let age = now.signed_duration_since(m.created_at);
147 let age = if age.num_hours() >= 1 {
148 format!("{}h ago", age.num_hours())
149 } else {
150 format!("{}m ago", age.num_minutes().max(0))
151 };
152 let state = match m.lifecycle {
153 L::Parked => "parked",
154 _ if m.clients > 0 => "live, attached",
155 _ => "live",
156 };
157 let short = m.id.as_str();
158 let name = m.name.as_deref().map(|n| format!(" \"{n}\"")).unwrap_or_default();
159 let cwd = m.cwd.as_ref().map(|c| format!(" {}", c.display())).unwrap_or_default();
160 line.push_str(&format!("\n {short}{name} {state} {age}{cwd}"));
161 }
162 if n > 5 {
163 line.push_str(&format!("\n … and {} more (`synaps daemon sessions`)", n - 5));
164 }
165 Some(line)
166}
167
168pub struct LiveSession {
170 pub id: SessionId,
171 pub model: String,
172 pub clients: usize,
173}
174
175pub fn choose_attach(
180 opts: &AttachOpts,
181 sessions: &[LiveSession],
182 cwd: Option<PathBuf>,
183 await_extensions: bool,
184) -> std::result::Result<(Attach, Option<String>), String> {
185 let create = |continue_session: Option<Option<String>>| Attach::Create {
186 config: SessionConfig {
187 continue_session,
188 system: opts.system.clone(),
189 prompt_manifest: opts.prompt_manifest.clone(),
190 cwd: cwd.clone(),
191 env: None,
192 compaction_policy: CompactionPolicyWire::LinkedSuccessor,
193 await_extensions,
194 keep_warm: opts.keep_warm,
195 name: opts.name.clone(),
196 ..Default::default()
197 },
198 mode: opts.mode,
199 };
200 if opts.new_session && opts.id.is_some() {
201 return Err("cannot combine --attach <ID> with --new: pick one".to_string());
202 }
203 if let Some(c) = &opts.continue_session {
204 return Ok((create(Some(c.clone())), None));
205 }
206 if opts.new_session {
207 return Ok((create(None), None));
208 }
209 if let Some(id) = &opts.id {
210 let sid = sessions
211 .iter()
212 .find(|m| m.id.as_str() == id || m.id.as_str().starts_with(id.as_str()))
213 .map(|m| m.id.clone())
214 .unwrap_or_else(|| SessionId::from(id.as_str()));
215 return Ok((
216 Attach::Existing {
217 session_id: sid,
218 mode: opts.mode,
219 },
220 None,
221 ));
222 }
223 match sessions {
224 [] => Ok((create(None), None)),
225 [only] => Ok((
226 Attach::Existing {
227 session_id: only.id.clone(),
228 mode: opts.mode,
229 },
230 Some(format!(
231 "attaching to live session {} (--new for a fresh one; `synaps daemon sessions` lists all)",
232 only.id
233 )),
234 )),
235 many => {
236 let mut lines =
237 vec!["several sessions; pick one with --attach <ID> (or --new for a fresh one):".to_string()];
238 for m in many {
239 lines.push(format!(" {} model={} clients={}", m.id, m.model, m.clients));
240 }
241 Err(lines.join("\n"))
242 }
243 }
244}
245
246pub fn attach_mode(observe: bool, takeover: bool) -> AttachMode {
248 if takeover {
249 return AttachMode::Takeover;
250 }
251 if observe {
252 return AttachMode::Observe;
253 }
254 match std::env::var("SYNAPS_ATTACH_MODE").ok().as_deref() {
255 Some("observe") => AttachMode::Observe,
256 Some("takeover") => AttachMode::Takeover,
257 _ => AttachMode::Mirror,
258 }
259}
260
261pub(super) fn skew_notice_once(
265 app: &mut super::app::App,
266 daemon: agent_engine::session::transport::BinaryId,
267 session: &str,
268) -> Option<String> {
269 use agent_engine::session::transport::{version_skew_notice, BinaryId};
270 if app.skew_noticed.as_ref() == Some(&daemon) {
271 return None;
272 }
273 let notice = version_skew_notice(&BinaryId::this(), &daemon, session)?;
274 app.skew_noticed = Some(daemon);
275 Some(notice)
276}
277
278pub(super) fn session_ended_line(reason: EndReason, session: &str, has_history: bool) -> Option<String> {
286 let what = match reason {
287 EndReason::ClientQuit => "was closed",
288 EndReason::HostShutdown => "ended: the daemon shut down",
289 EndReason::Evicted => "was evicted from the daemon",
290 EndReason::Error => "ended after an error",
291 EndReason::Idle => return None,
292 };
293 Some(if has_history {
294 format!("synaps: session {session} {what}; resume it with: synaps --attach --continue {session}")
295 } else {
296 format!("synaps: session {session} {what} (it never ran a turn, so there is nothing to resume)")
297 })
298}
299
300fn cfg_err(e: impl std::fmt::Display) -> synaps_cli::RuntimeError {
301 synaps_cli::RuntimeError::Config(e.to_string())
302}
303
304pub fn daemon_not_running_message(profile: Option<&str>, detail: Option<&str>) -> String {
307 let start = match profile {
308 Some(p) => format!("synaps --profile {p} daemon --detach"),
309 None => "synaps daemon --detach".to_string(),
310 };
311 let why = detail.map(|d| format!(" ({d})")).unwrap_or_default();
312 format!(
313 "no daemon is running{why} — nothing to attach to.\n\
314 start one with: {start}\n\
315 or run in the foreground: {}\n\
316 then re-run `synaps --attach` (or unset SYNAPS_DAEMON_AUTOSPAWN=0 to auto-start).",
317 start.replace("--detach", "--foreground")
318 )
319}
320
321pub async fn run_attached(mut opts: AttachOpts) -> Result<()> {
322 ladder("attach:enter", &"");
323 let paths = agent_engine::daemon::registry::daemon_paths(opts.profile.as_deref());
324 if !agent_engine::daemon::registry::is_alive(&paths) {
325 return Err(cfg_err(daemon_not_running_message(
326 opts.profile.as_deref(),
327 None,
328 )));
329 }
330 let client_name = opts.client_name.clone().or_else(agent_engine::session::client_name_from_env);
331 if let Some(name) = client_name.as_deref() {
332 agent_engine::session::validate_client_name(name).map_err(cfg_err)?;
333 }
334 let hello = Hello::new(ClientKind::Tui)
335 .with_name(client_name)
336 .with_history(HistoryMode::from_env_or(HistoryMode::attach_client_default()));
337 let conn = match SocketTransport::connect(&paths.sock, hello).await {
338 Ok(c) => c,
339 Err(TransportError::Version { client, daemon }) => {
340 return Err(cfg_err(format!(
341 "protocol version mismatch (client {client}, daemon {daemon}); restart or reload the daemon with this binary"
342 )))
343 }
344 Err(TransportError::Io(e)) => {
345 return Err(cfg_err(daemon_not_running_message(
348 opts.profile.as_deref(),
349 Some(&format!("{}: {e}", paths.sock.display())),
350 )));
351 }
352 Err(e) => return Err(cfg_err(format!("connect: {e}"))),
353 };
354 if let Some(msg) =
355 agent_engine::daemon::profile_mismatch(conn.welcome.profile.as_deref(), opts.profile.as_deref())
356 {
357 return Err(cfg_err(msg));
358 }
359 ladder(
360 "connect",
361 &format_args!("sessions={}", conn.welcome.sessions.len()),
362 );
363
364 let cwd = std::env::current_dir().ok();
365
366 if let Some(ref cwd_path) = cwd {
369 let mut probe_cfg = SessionConfig {
370 system: opts.system.clone(),
371 prompt_manifest: opts.prompt_manifest.clone(),
372 ..Default::default()
373 };
374 if let Err(e) = agent_engine::session::client_args::resolve_client_session_args(
375 &mut probe_cfg,
376 cwd_path,
377 ) {
378 eprintln!("synaps: {e}");
380 return Err(cfg_err(e));
381 }
382 opts.system = probe_cfg.system;
383 opts.prompt_manifest = probe_cfg.prompt_manifest;
384 }
385
386 let live: Vec<LiveSession> = conn
387 .welcome
388 .sessions
389 .iter()
390 .map(|m| LiveSession {
391 id: m.id.clone(),
392 model: m.model.clone(),
393 clients: m.clients,
394 })
395 .collect();
396 let welcome_sessions = conn.welcome.sessions.clone();
397 let await_extensions = !synaps_cli::load_config().startup.quick_start;
401 let (attach, notice) =
402 choose_attach(&opts, &live, cwd, await_extensions).map_err(cfg_err)?;
403 if let Some(n) = notice {
404 eprintln!("{n}");
406 }
407
408 let (transport, snapshot) = SocketTransport::attach(conn, attach)
409 .await
410 .map_err(|e| cfg_err(format!("attach: {e}")))?;
411 ladder(
414 "attached",
415 &format_args!(
416 "frame_bytes=n/a messages_len={} api_messages={} replay={} tail_items={}",
417 snapshot.conversation.messages_len,
418 snapshot.conversation.api_messages.len(),
419 snapshot.replay.len(),
420 agent_engine::session::DEFAULT_TAIL_ITEMS
421 ),
422 );
423 super::client_diet::purge_arenas("purge:attached");
425
426 let config = synaps_cli::load_config();
428 let registry = Arc::new(CommandRegistry::new(BUILTIN_COMMANDS, Vec::new()));
429 let mut keybinds = synaps_cli::skills::keybinds::KeybindRegistry::new();
430 if !config.keybinds.is_empty() {
431 keybinds.register_user(&config.keybinds);
432 }
433 let keybind_registry = Arc::new(std::sync::RwLock::new(keybinds));
434 let system_prompt_path = synaps_cli::config::resolve_read_path("system.md");
435 ladder("config", &"");
436 let http = LazyHttp::new();
437 if std::env::var("SYNAPS_CLIENT_HTTP").is_ok_and(|v| v == "eager") {
438 http.get()?;
439 }
440
441 let mut app = app_from_snapshot(&snapshot);
442 app.observing = transport.mode() == AttachMode::Observe;
443 app.attached_clients = transport.roster().clients().len();
444 let (msgs, bytes) = super::app::scrollback_from_env(&TransportMode::Socket);
445 app.transcript.set_scrollback(msgs, bytes);
446 ladder("app", &"");
447 for n in super::run_setup::take_boot_notices() {
449 app.push_msg(ChatMessage::System(n));
450 }
451 let notices = attach_notices(AttachNoticeInputs {
452 session_id: &snapshot.meta.id,
453 client_id: transport.client_id().0,
454 mode: transport.mode(),
455 owned_elsewhere: snapshot.input_owned_elsewhere(transport.client_id()),
456 adopted: opts.adopted.then_some(opts.continue_session.is_some()),
457 others_banner: if opts.adopted {
458 adopt_banner(&welcome_sessions, &snapshot.meta.id, chrono::Utc::now())
459 } else {
460 None
461 },
462 config_warnings: &config.warnings,
463 version_skew: skew_notice_once(
464 &mut app,
465 agent_engine::session::transport::BinaryId::of(&transport.welcome),
466 snapshot.meta.id.as_str(),
467 ),
468 });
469 for n in notices.transcript {
470 app.push_msg(ChatMessage::System(n));
471 }
472 if !notices.toast.is_empty() {
473 app.toasts.upsert(
474 super::toast::Toast::new(ATTACH_TOAST_ID, String::new())
475 .titled("daemon")
476 .lines(notices.toast),
477 );
478 }
479 let replay = snapshot.replay.clone();
482 let pending = snapshot.pending_prompts.clone();
483 let owns_input = super::session_link::may_drive(&transport);
484 app.streaming = snapshot.streaming;
485
486 let mut ctx = finish_setup(
487 app,
488 Box::new(transport),
489 http,
490 TransportMode::Socket,
491 config,
492 registry,
493 keybind_registry,
494 system_prompt_path,
495 None,
496 )
497 .await?;
498 ctx.app.extension_loader_running = false;
500 ctx.prompt_bridge.set_owns_input(owns_input);
504 if opts.no_confirm_answers || opts.mode == AttachMode::Observe {
505 ctx.prompt_bridge
506 .set_confirm_policy(super::session_link::ConfirmPolicy::ViewOnly);
507 }
508 for env in replay {
509 let _ = super::stream_handler::handle_session_event_arm(
511 env,
512 &mut ctx.app,
513 &mut ctx.link,
514 &ctx.registry,
515 &ctx.render_handle,
516 &mut ctx.prompt_bridge,
517 None,
518 )
519 .await;
520 }
521 for pr in pending {
522 ctx.prompt_bridge.on_prompt(pr);
523 }
524 super::stream_handler::sync_waiting_confirms(&mut ctx.app, &ctx.prompt_bridge);
525 ladder("replay", &"");
526 if opts.keep_warm {
527 let _ = ctx.link.send(SessionCommand::KeepWarm { on: true }).await;
528 }
529 super::run_loop(ctx).await
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
540 fn an_ended_session_says_how_to_continue_it() {
541 let line = session_ended_line(EndReason::ClientQuit, "20261014-183459-3f61", true).unwrap();
542 assert_eq!(
543 line,
544 "synaps: session 20261014-183459-3f61 was closed; resume it with: \
545 synaps --attach --continue 20261014-183459-3f61"
546 );
547 let stop = session_ended_line(EndReason::HostShutdown, "s1", true).unwrap();
548 assert!(stop.contains("the daemon shut down") && stop.ends_with("--continue s1"), "{stop}");
549 assert!(session_ended_line(EndReason::Evicted, "s1", true).unwrap().contains("evicted"));
550 assert!(session_ended_line(EndReason::Error, "s1", true).unwrap().contains("error"));
551 let empty = session_ended_line(EndReason::ClientQuit, "s1", false).unwrap();
552 assert_eq!(empty, "synaps: session s1 was closed (it never ran a turn, so there is nothing to resume)");
553 assert_eq!(session_ended_line(EndReason::Idle, "s1", true), None);
554 }
555
556 fn notice_inputs<'a>(id: &'a SessionId, warnings: &'a [String]) -> AttachNoticeInputs<'a> {
557 AttachNoticeInputs {
558 session_id: id,
559 client_id: 1,
560 mode: AttachMode::Mirror,
561 owned_elsewhere: None,
562 adopted: None,
563 others_banner: None,
564 config_warnings: warnings,
565 version_skew: None,
566 }
567 }
568
569 #[test]
570 fn routine_attach_notes_go_to_the_toast_so_the_logo_shows() {
571 let id = SessionId::from("20261001-000000-ours".to_string());
572 let n = attach_notices(AttachNoticeInputs { adopted: Some(false), ..notice_inputs(&id, &[]) });
574 assert!(n.transcript.is_empty(), "a fresh session's transcript stays empty: {n:?}");
575 assert_eq!(n.toast[0], "attached to 20261001-000000-ours as client #1 (Mirror)");
576 assert_eq!(n.toast[1], "adopted the running daemon (fresh session)");
577 assert!(n.toast[2].contains("SYNAPS_DAEMON_ADOPT=0"), "{n:?}");
578 let n = attach_notices(AttachNoticeInputs { adopted: Some(true), ..notice_inputs(&id, &[]) });
580 assert_eq!(n.toast[1], "adopted the running daemon (continued session)");
581 let n = attach_notices(notice_inputs(&id, &[]));
583 assert_eq!(n, AttachNotices { transcript: vec![], toast: vec![
584 "attached to 20261001-000000-ours as client #1 (Mirror)".to_string(),
585 ] });
586 }
587
588 #[test]
589 fn notes_that_need_the_user_stay_in_the_transcript() {
590 let id = SessionId::from("20261001-000000-ours".to_string());
591 let warnings = vec!["unknown key `fooo`".to_string()];
592 let n = attach_notices(AttachNoticeInputs {
593 adopted: Some(false),
594 others_banner: Some("2 other sessions in this daemon".to_string()),
595 owned_elsewhere: Some("input is owned by client #3 (tui)".to_string()),
596 ..notice_inputs(&id, &warnings)
597 });
598 assert_eq!(n.transcript, vec![
599 "2 other sessions in this daemon".to_string(),
600 "⚠ config: unknown key `fooo`".to_string(),
601 "attached to 20261001-000000-ours as client #1 (Mirror) — input is owned by client #3 (tui), or /takeover here"
602 .to_string(),
603 ]);
604 assert!(n.toast.iter().all(|l| !l.starts_with("attached to")), "{n:?}");
606 assert_eq!(n.toast[0], "adopted the running daemon (fresh session)");
607 }
608
609 #[test]
612 fn an_observer_is_not_offered_takeover() {
613 let id = SessionId::from("20261001-000000-ours".to_string());
614 let n = attach_notices(AttachNoticeInputs {
615 mode: AttachMode::Observe,
616 owned_elsewhere: Some("input is owned by client #3 (tui)".to_string()),
617 ..notice_inputs(&id, &[])
618 });
619 assert_eq!(n.transcript, vec![
620 "attached to 20261001-000000-ours as client #1 (Observe) — input is owned by client #3 (tui)"
621 .to_string(),
622 ]);
623 }
624
625 #[test]
626 fn a_client_older_than_the_daemon_says_so_in_the_transcript() {
627 let id = SessionId::from("20261001-000000-ours".to_string());
628 use agent_engine::session::transport::{version_skew_notice, BinaryId};
629 let bin = |version: &str| BinaryId { version: version.into(), build: String::new() };
630 let skew = version_skew_notice(&bin("0.10.1"), &bin("0.10.2"), id.as_str());
631 let n = attach_notices(AttachNoticeInputs { version_skew: skew, ..notice_inputs(&id, &[]) });
632 assert_eq!(n.transcript.len(), 1, "{n:?}");
633 assert!(n.transcript[0].starts_with("⚠ this client is synaps 0.10.1 but the daemon is 0.10.2"), "{n:?}");
634 assert!(n.transcript[0].contains("synaps --attach 20261001-000000-ours"), "{n:?}");
635 let same = version_skew_notice(&bin("0.10.2"), &bin("0.10.2"), id.as_str());
637 let n = attach_notices(AttachNoticeInputs { version_skew: same, ..notice_inputs(&id, &[]) });
638 assert!(n.transcript.is_empty(), "{n:?}");
639 }
640
641 #[test]
644 fn the_skew_notice_shows_once_per_daemon_build() {
645 use agent_engine::session::transport::BinaryId;
646 let mut app = crate::tui::app::App::new(synaps_cli::Session::new("test-model", "low", None));
647 let daemon = |build: &str| BinaryId { version: "0.0.1".into(), build: build.into() };
648 assert!(skew_notice_once(&mut app, daemon("aaaa1111"), "s").is_some());
649 assert!(
650 skew_notice_once(&mut app, daemon("aaaa1111"), "s").is_none(),
651 "a reconnect to the same daemon build says nothing again"
652 );
653 assert!(
654 skew_notice_once(&mut app, daemon("bbbb2222"), "s").is_some(),
655 "a reload onto another daemon build warns again"
656 );
657 }
658
659 #[test]
660 fn not_running_message_says_so_and_how_to_start() {
661 let m = daemon_not_running_message(None, None);
662 assert!(m.starts_with("no daemon is running"), "{m}");
663 assert!(m.contains("synaps daemon --detach"), "{m}");
664 assert!(m.contains("synaps daemon --foreground"), "{m}");
665 let m = daemon_not_running_message(Some("work"), Some("connection refused"));
666 assert!(m.contains("(connection refused)"), "{m}");
667 assert!(m.contains("synaps --profile work daemon --detach"), "{m}");
668 }
669
670 fn opts(id: Option<&str>, new_session: bool) -> AttachOpts {
671 AttachOpts {
672 profile: None,
673 id: id.map(str::to_string),
674 continue_session: None,
675 system: None,
676 prompt_manifest: None,
677 mode: AttachMode::Mirror,
678 no_confirm_answers: false,
679 keep_warm: false,
680 new_session,
681 name: None,
682 client_name: None,
683 adopted: false,
684 }
685 }
686
687 fn meta(id: &str, lifecycle: agent_engine::session::SessionLifecycle, clients: usize, mins_ago: i64) -> agent_engine::session::SessionMeta {
688 agent_engine::session::SessionMeta {
689 id: SessionId::from(id.to_string()),
690 name: None,
691 model: "m".into(),
692 cwd: Some(std::path::PathBuf::from("/w")),
693 created_at: chrono::Utc::now() - chrono::Duration::minutes(mins_ago),
694 continued: false,
695 continue_info: None,
696 host_pid: 1,
697 lifecycle,
698 clients,
699 input_owner: None,
700 awaiting_input: 0,
701 roster: Vec::new(),
702 client_input: None,
703 journal_id: String::new(),
704 locked_by: None,
705 pinned: false,
706 busy: false,
707 state_since: None,
708 park_due: None,
709 evict_due: None,
710 title: None,
711 cost_usd: 0.0,
712 messages: 0,
713 }
714 }
715
716 #[test]
717 fn await_extensions_flows_from_quick_start() {
718 let (a, _) = choose_attach(&opts(None, true), &[], None, true).unwrap();
721 match a {
722 Attach::Create { config, .. } => assert!(config.await_extensions),
723 other => panic!("expected Create, got {other:?}"),
724 }
725 let (a, _) = choose_attach(&opts(None, true), &[], None, false).unwrap();
727 match a {
728 Attach::Create { config, .. } => assert!(!config.await_extensions),
729 other => panic!("expected Create, got {other:?}"),
730 }
731 }
732
733 #[test]
734 fn bare_continue_means_most_recent_not_fresh() {
735 let mut o = opts(None, true);
739 o.continue_session = Some(None);
740 let (a, _) = choose_attach(&o, &[live("abc")], None, true).unwrap();
741 match a {
742 Attach::Create { config, .. } => assert_eq!(config.continue_session, Some(None)),
743 other => panic!("expected Create, got {other:?}"),
744 }
745 let mut o = opts(None, false);
746 o.continue_session = Some(Some("xyz".into()));
747 let (a, _) = choose_attach(&o, &[], None, true).unwrap();
748 match a {
749 Attach::Create { config, .. } => assert_eq!(config.continue_session, Some(Some("xyz".into()))),
750 other => panic!("expected Create, got {other:?}"),
751 }
752 }
753
754 #[test]
755 fn adopt_banner_lists_other_sessions_only() {
756 use agent_engine::session::SessionLifecycle as L;
757 let ours = SessionId::from("20260919-000000-ours".to_string());
758 let now = chrono::Utc::now();
759 assert!(adopt_banner(&[meta("20260919-000000-ours", L::Live, 1, 0)], &ours, now).is_none());
761 let sessions = vec![
762 meta("20260919-000000-ours", L::Live, 1, 0),
763 meta("20260919-010000-aaaa", L::Parked, 0, 125),
764 meta("20260919-020000-bbbb", L::Live, 1, 3),
765 ];
766 let b = adopt_banner(&sessions, &ours, now).unwrap();
767 assert!(b.starts_with("2 other sessions in this daemon (1 live, 1 parked)"), "{b}");
768 assert!(!b.contains("ours"), "{b}");
769 let rows: Vec<&str> = b.lines().skip(1).collect();
771 assert!(rows[0].contains("20260919-020000-bbbb") && rows[0].contains("live, attached") && rows[0].contains("m ago") && rows[0].contains("/w"), "{b}");
772 assert!(rows[1].contains("20260919-010000-aaaa") && rows[1].contains("parked") && rows[1].contains("2h ago"), "{b}");
773 let many: Vec<_> = (0..8).map(|i| meta(&format!("20260919-00000{i}-xxxx"), L::Parked, 0, i)).collect();
775 let b = adopt_banner(&many, &ours, now).unwrap();
776 assert_eq!(b.lines().count(), 1 + 5 + 1, "{b}");
777 assert!(b.contains("and 3 more"), "{b}");
778 }
779
780 fn live(id: &str) -> LiveSession {
781 LiveSession {
782 id: SessionId::from(id),
783 model: "m".into(),
784 clients: 1,
785 }
786 }
787
788 #[test]
789 fn new_creates_even_with_live_sessions() {
790 let (a, notice) = choose_attach(&opts(None, true), &[live("abc")], None, true).unwrap();
791 assert!(matches!(a, Attach::Create { .. }), "{a:?}");
792 assert!(notice.is_none());
793 let (a, _) = choose_attach(&opts(None, true), &[live("a"), live("b")], None, true).unwrap();
794 assert!(matches!(a, Attach::Create { .. }), "{a:?}");
795 }
796
797 #[test]
798 fn new_with_explicit_id_is_rejected() {
799 let e = choose_attach(&opts(Some("abc"), true), &[live("abc")], None, true).unwrap_err();
800 assert!(e.contains("cannot combine"), "{e}");
801 }
802
803 #[test]
804 fn sole_live_session_is_picked_with_notice() {
805 let (a, notice) = choose_attach(&opts(None, false), &[live("abc")], None, true).unwrap();
806 assert!(
807 matches!(&a, Attach::Existing { session_id, .. } if session_id.as_str() == "abc"),
808 "{a:?}"
809 );
810 let n = notice.unwrap();
811 assert!(n.contains("abc") && n.contains("--new") && n.contains("daemon sessions"), "{n}");
812 }
813
814 #[test]
815 fn no_live_sessions_creates_and_many_errors_with_hint() {
816 let (a, _) = choose_attach(&opts(None, false), &[], None, true).unwrap();
817 assert!(matches!(a, Attach::Create { .. }), "{a:?}");
818 let e = choose_attach(&opts(None, false), &[live("a"), live("b")], None, true).unwrap_err();
819 assert!(e.contains("--new") && e.contains(" a ") && e.contains(" b "), "{e}");
820 }
821
822 #[test]
823 fn attach_mode_flags_override_env() {
824 assert_eq!(attach_mode(false, true), AttachMode::Takeover);
825 assert_eq!(attach_mode(true, false), AttachMode::Observe);
826 }
827}