1use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
8use std::sync::Arc;
9
10use tokio::sync::{broadcast, mpsc};
11
12use super::transport::TransportError;
13use super::types::{Addressed, ClientId, Envelope, SessionCommand, SessionConfig, SessionId, SessionLifecycle, SessionMeta};
14
15#[derive(Clone, Debug, Default, PartialEq)]
17pub struct Presence {
18 pub clients: usize,
19 pub input_owner: Option<ClientId>,
20 pub awaiting_input: usize,
21 pub roster: Vec<super::types::ClientInfo>,
23 pub client_input: Option<super::types::ClientInput>,
25 pub busy: bool,
27 pub state: PresenceState,
30 pub state_since: Option<chrono::DateTime<chrono::Utc>>,
31 pub park_due: Option<chrono::DateTime<chrono::Utc>>,
33 pub evict_due: Option<chrono::DateTime<chrono::Utc>>,
35 pub title: Option<String>,
38 pub cost_usd: f64,
39 pub messages: usize,
40}
41
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum PresenceState {
45 #[default]
46 Idle,
47 Busy,
48 Parked,
49}
50use super::view::RuntimeView;
51
52pub const CMD_CHAN_CAP: usize = 256;
54pub const DEFAULT_EVENTS_CAP: usize = 1024;
56
57pub fn events_cap() -> usize {
59 std::env::var("SYNAPS_SESSION_EVENTS_CAP")
60 .ok()
61 .and_then(|s| s.parse::<usize>().ok())
62 .map(|n| n.max(16))
63 .unwrap_or(DEFAULT_EVENTS_CAP)
64}
65
66#[derive(Clone)]
67pub struct SessionHandle {
68 pub id: SessionId,
69 cmd_tx: mpsc::Sender<Addressed>,
70 events: broadcast::Sender<Envelope>,
71 meta: Arc<SessionMeta>,
72 view: Arc<arc_swap::ArcSwap<RuntimeView>>,
74 lifecycle: Arc<AtomicU8>,
76 journal_id: Arc<arc_swap::ArcSwap<String>>,
78 presence: Arc<arc_swap::ArcSwap<Presence>>,
80 name: Arc<arc_swap::ArcSwap<Option<String>>>,
83 keep_warm: Arc<AtomicBool>,
86 cache_keepwarm: super::cache_keepwarm::OverrideSlot,
90 memory_mode: MemoryModeSlot,
94 placeholder_config: Option<Arc<SessionConfig>>,
97}
98
99impl std::fmt::Debug for SessionHandle {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.debug_struct("SessionHandle")
102 .field("id", &self.id)
103 .field("model", &self.meta.model)
104 .finish()
105 }
106}
107
108pub struct SessionEndpoints {
110 pub cmd_rx: mpsc::Receiver<Addressed>,
111 pub events: broadcast::Sender<Envelope>,
112 pub view: Arc<arc_swap::ArcSwap<RuntimeView>>,
113 pub lifecycle: Arc<AtomicU8>,
114 pub journal_id: Arc<arc_swap::ArcSwap<String>>,
115 pub presence: Arc<arc_swap::ArcSwap<Presence>>,
116 pub name: Arc<arc_swap::ArcSwap<Option<String>>>,
117 pub keep_warm: Arc<AtomicBool>,
118 pub cache_keepwarm: super::cache_keepwarm::OverrideSlot,
119 pub memory_mode: MemoryModeSlot,
120}
121
122pub type MemoryModeSlot = Arc<std::sync::Mutex<Option<super::SessionMemoryMode>>>;
124
125impl SessionHandle {
126 pub fn new(meta: SessionMeta, view: RuntimeView) -> (Self, SessionEndpoints) {
129 let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHAN_CAP);
130 let (events, _) = broadcast::channel(events_cap());
131 let view = Arc::new(arc_swap::ArcSwap::from_pointee(view));
132 let lifecycle = Arc::new(AtomicU8::new(SessionLifecycle::Live as u8));
133 let journal_id = Arc::new(arc_swap::ArcSwap::from_pointee(meta.id.0.clone()));
134 let presence = Arc::new(arc_swap::ArcSwap::from_pointee(Presence::default()));
135 let name = Arc::new(arc_swap::ArcSwap::from_pointee(meta.name.clone()));
136 let keep_warm = Arc::new(AtomicBool::new(meta.pinned));
137 let cache_keepwarm = super::cache_keepwarm::OverrideSlot::default();
138 let memory_mode = MemoryModeSlot::default();
139 let handle = Self {
140 id: meta.id.clone(),
141 cmd_tx,
142 events: events.clone(),
143 meta: Arc::new(meta),
144 view: Arc::clone(&view),
145 lifecycle: Arc::clone(&lifecycle),
146 journal_id: Arc::clone(&journal_id),
147 presence: Arc::clone(&presence),
148 name: Arc::clone(&name),
149 keep_warm: Arc::clone(&keep_warm),
150 cache_keepwarm: Arc::clone(&cache_keepwarm),
151 memory_mode: Arc::clone(&memory_mode),
152 placeholder_config: None,
153 };
154 (
155 handle,
156 SessionEndpoints {
157 cmd_rx,
158 events,
159 view,
160 lifecycle,
161 journal_id,
162 presence,
163 name,
164 keep_warm,
165 cache_keepwarm,
166 memory_mode,
167 },
168 )
169 }
170
171 pub async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError> {
175 self.send_addressed(Addressed { from: None, cmd })
176 }
177
178 pub async fn send_from(&self, from: ClientId, cmd: SessionCommand) -> Result<(), TransportError> {
181 self.send_addressed(Addressed { from: Some(from), cmd })
182 }
183
184 fn send_addressed(&self, cmd: Addressed) -> Result<(), TransportError> {
185 match self.cmd_tx.try_send(cmd) {
186 Ok(()) => Ok(()),
187 Err(mpsc::error::TrySendError::Full(_)) => Err(TransportError::Backpressure),
188 Err(mpsc::error::TrySendError::Closed(_)) => Err(TransportError::Closed),
189 }
190 }
191
192 pub fn lifecycle(&self) -> SessionLifecycle {
194 SessionLifecycle::from_u8(self.lifecycle.load(Ordering::Acquire))
195 }
196
197 pub fn journal_id(&self) -> String {
199 (**self.journal_id.load()).clone()
200 }
201
202 pub fn presence(&self) -> Presence {
204 (**self.presence.load()).clone()
205 }
206
207 pub fn name(&self) -> Option<String> {
209 (**self.name.load()).clone()
210 }
211
212 pub fn pinned(&self) -> bool {
215 self.keep_warm.load(Ordering::Acquire)
216 }
217
218 pub fn cache_keepwarm_override(&self) -> Option<String> {
220 self.cache_keepwarm
221 .lock()
222 .unwrap_or_else(std::sync::PoisonError::into_inner)
223 .map(|m| m.to_string())
224 }
225
226 pub fn memory_mode(&self) -> Option<super::SessionMemoryMode> {
228 *self
229 .memory_mode
230 .lock()
231 .unwrap_or_else(std::sync::PoisonError::into_inner)
232 }
233
234 pub fn meta_live(&self) -> SessionMeta {
238 let mut m = (*self.meta).clone();
239 let p = self.presence();
240 m.lifecycle = self.lifecycle();
241 m.clients = p.clients;
242 m.input_owner = p.input_owner;
243 m.awaiting_input = p.awaiting_input;
244 m.roster = p.roster;
245 m.client_input = p.client_input;
246 m.model = self.view.load().model.clone();
248 m.journal_id = self.journal_id();
249 m.name = self.name();
250 m.pinned = self.pinned();
251 m.busy = p.busy;
252 m.state_since = p.state_since;
253 m.park_due = p.park_due;
254 m.evict_due = p.evict_due;
255 m.title = p.title;
256 m.cost_usd = p.cost_usd;
257 m.messages = p.messages;
258 m
259 }
260
261 pub fn subscribe(&self) -> broadcast::Receiver<Envelope> {
262 self.events.subscribe()
263 }
264
265 pub fn placeholder_config(&self) -> Option<&SessionConfig> {
267 self.placeholder_config.as_deref()
268 }
269
270 pub(crate) fn with_placeholder_config(mut self, cfg: SessionConfig) -> Self {
271 self.placeholder_config = Some(Arc::new(cfg));
272 self
273 }
274
275 pub fn meta(&self) -> &SessionMeta {
276 &self.meta
277 }
278
279 pub fn view(&self) -> arc_swap::Guard<Arc<RuntimeView>> {
280 self.view.load()
281 }
282
283 pub fn is_alive(&self) -> bool {
285 !self.cmd_tx.is_closed()
286 }
287
288 pub async fn closed(&self) {
290 self.cmd_tx.closed().await
291 }
292
293 #[cfg(any(test, feature = "testing"))]
296 pub fn echo_for_test(id: SessionId) -> (Self, tokio::task::JoinHandle<()>) {
297 echo::spawn(id)
298 }
299}
300
301#[cfg(any(test, feature = "testing"))]
302pub mod echo {
303 use std::collections::HashMap;
308
309 use agent_core::reasoning::ReasoningLevel;
310
311 use super::*;
312 use crate::session::types::*;
313 use crate::{LlmEvent, SessionEvent, StreamEvent};
314
315 pub fn meta_for(id: &SessionId) -> SessionMeta {
316 SessionMeta {
317 id: id.clone(),
318 name: None,
319 model: "echo".into(),
320 cwd: None,
321 created_at: chrono::Utc::now(),
322 continued: false,
323 continue_info: None,
324 host_pid: std::process::id(),
325 lifecycle: SessionLifecycle::Live,
326 clients: 0,
327 input_owner: None,
328 awaiting_input: 0,
329 roster: Vec::new(),
330 client_input: None,
331 journal_id: id.0.clone(),
332 locked_by: None,
333 pinned: false,
334 busy: false,
335 state_since: None,
336 park_due: None,
337 evict_due: None,
338 title: None,
339 cost_usd: 0.0,
340 messages: 0,
341 }
342 }
343
344 pub fn view_for() -> RuntimeView {
345 RuntimeView {
346 model: "echo".into(),
347 thinking_level: "off".into(),
348 reasoning_level: ReasoningLevel::Off,
349 is_reasoning_explicit: false,
350 thinking_budget: 0,
351 context_window: 200_000,
352 system_prompt: None,
353 compaction_model: "echo".into(),
354 api_retries: 0,
355 subagent_timeout: 0,
356 max_tool_output: 4096,
357 bash_timeout: 30,
358 bash_max_timeout: 300,
359 prompt_generation: 0,
360 hook_handler_count: 0,
361 prompt_inspection: None,
362 }
363 }
364
365 pub fn spawn(id: SessionId) -> (SessionHandle, tokio::task::JoinHandle<()>) {
366 let (handle, ep) = SessionHandle::new(meta_for(&id), view_for());
367 let task = tokio::spawn(run(id, ep));
368 (handle, task)
369 }
370
371 struct Echo {
372 id: SessionId,
373 events: broadcast::Sender<Envelope>,
374 view: Arc<arc_swap::ArcSwap<RuntimeView>>,
375 seq: u64,
376 next_client: u64,
377 clients: HashMap<ClientId, ClientMeta>,
378 conv: ConversationSnapshot,
379 }
380
381 impl Echo {
382 fn emit(&mut self, event: SessionEventWire) {
383 let env = Envelope {
384 session_id: self.id.clone(),
385 seq: self.seq,
386 ts: chrono::Utc::now(),
387 event,
388 };
389 self.seq += 1;
390 let _ = self.events.send(env);
392 }
393
394 fn conv_snapshot(&self) -> ConversationSnapshot {
395 let mut c = self.conv.clone();
396 c.messages_len = c.api_messages.len();
397 c
398 }
399
400 fn snapshot_for(&self, client: &ClientMeta) -> AttachSnapshot {
403 let mut conversation = self.conv_snapshot();
404 let display_tail = (client.history == HistoryMode::Digest).then(|| {
405 let t = crate::session::display::display_tail(&conversation.api_messages, client.tail_items);
406 conversation.api_messages = Vec::new();
407 t
408 });
409 AttachSnapshot {
410 meta: meta_for(&self.id),
411 view: (**self.view.load()).clone(),
412 conversation,
413 streaming: false,
414 replay: Vec::new(),
415 pending_prompts: Vec::new(),
416 clients: self.clients.iter().map(|(c, m)| (*c, m.kind)).collect(),
417 roster: Vec::new(),
418 client_input: ClientInput::wire_default(),
419 input_owner: None,
420 display_tail,
421 }
422 }
423
424 fn handle(&mut self, cmd: SessionCommand) -> bool {
425 match cmd {
426 SessionCommand::Attach { client, mode } => {
427 let cid = ClientId(self.next_client);
428 self.next_client += 1;
429 let kind = client.kind;
430 let snapshot_meta = client.clone();
431 self.clients.insert(cid, client);
432 if mode != AttachMode::Mirror {
433 self.emit(SessionEventWire::SystemNotice(format!(
434 "attach mode {mode:?} not supported yet; using mirror"
435 )));
436 }
437 let snapshot = self.snapshot_for(&snapshot_meta);
438 self.emit(SessionEventWire::Attached {
439 client: cid,
440 snapshot,
441 });
442 let name = kind.default_name().to_string();
443 self.emit(SessionEventWire::ClientJoined { client: cid, kind, name, mode: AttachMode::Mirror });
444 }
445 SessionCommand::Detach { client } => {
446 if self.clients.remove(&client).is_some() {
447 self.emit(SessionEventWire::ClientLeft { client });
448 }
449 }
450 SessionCommand::Submit { text, .. } => {
451 let baseline = self.conv.api_messages.len();
452 self.conv.api_messages.push(Arc::new(serde_json::json!({
453 "role": "user", "content": text
454 })));
455 self.emit(SessionEventWire::TurnStarted {
456 turn_baseline: baseline,
457 trigger: TurnTrigger::User,
458 user_text: None,
459 });
460 self.emit(SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(
461 text.clone(),
462 ))));
463 self.conv.api_messages.push(Arc::new(serde_json::json!({
464 "role": "assistant", "content": text
465 })));
466 self.emit(SessionEventWire::Stream(StreamEvent::Session(
467 SessionEvent::MessageHistory(self.conv.api_messages.clone()),
468 )));
469 self.emit(SessionEventWire::Stream(StreamEvent::Session(
470 SessionEvent::Done,
471 )));
472 let snap = self.conv_snapshot();
473 self.emit(SessionEventWire::Conversation(snap));
474 }
475 SessionCommand::Steer { text } => {
476 self.conv.queued_message = Some(text.clone());
477 self.emit(SessionEventWire::Steered {
478 text,
479 delivered: false,
480 });
481 }
482 SessionCommand::Cancel => {
483 let snap = self.conv_snapshot();
484 self.emit(SessionEventWire::Conversation(snap));
485 }
486 SessionCommand::Query { id, query } => {
487 let value = match query {
488 SessionQuery::View => {
489 serde_json::to_value(&**self.view.load()).unwrap_or_default()
490 }
491 SessionQuery::Messages => {
492 serde_json::to_value(&self.conv.api_messages).unwrap_or_default()
493 }
494 SessionQuery::DisplayTail { items } => serde_json::to_value(
495 super::super::display::display_tail(&self.conv.api_messages, items),
496 )
497 .unwrap_or_default(),
498 other => serde_json::json!({ "unsupported": format!("{other:?}") }),
499 };
500 self.emit(SessionEventWire::QueryResult { id, value });
501 }
502 SessionCommand::Set { id, setting } => {
503 let mut view = (**self.view.load()).clone();
504 if let SessionSetting::Model { model } = &setting {
505 view.model = model.clone();
506 }
507 self.view.store(Arc::new(view.clone()));
508 self.emit(SessionEventWire::SettingChanged(SettingApplied {
509 id,
510 setting: "echo".into(),
511 ok: true,
512 message: None,
513 view,
514 clamp: None,
515 }));
516 }
517 SessionCommand::End { reason } => {
518 self.emit(SessionEventWire::Ended { reason });
519 return false;
520 }
521 other => {
522 self.emit(SessionEventWire::SystemNotice(format!("echo: {other:?}")));
523 }
524 }
525 true
526 }
527 }
528
529 async fn run(id: SessionId, mut ep: SessionEndpoints) {
530 let mut echo = Echo {
531 id,
532 events: ep.events,
533 view: ep.view,
534 seq: 0,
535 next_client: 1,
536 clients: HashMap::new(),
537 conv: ConversationSnapshot::default(),
538 };
539 while let Some(Addressed { cmd, .. }) = ep.cmd_rx.recv().await {
540 if !echo.handle(cmd) {
541 break;
542 }
543 }
544 }
545}
546
547pub mod locked_placeholder {
551 use std::sync::atomic::Ordering;
552
553 use super::*;
554 use crate::session::types::*;
555
556
557 pub fn spawn(
562 id: SessionId,
563 config: SessionConfig,
564 holder_desc: String,
565 ) -> (SessionHandle, tokio::task::JoinHandle<()>) {
566 let meta = SessionMeta {
567 id: id.clone(),
568 name: config.name.clone(),
569 model: config.model_override.clone().unwrap_or_else(|| "unknown".into()),
570 cwd: config.cwd.clone(),
571 created_at: chrono::Utc::now(),
572 continued: true,
573 continue_info: None,
574 host_pid: std::process::id(),
575 lifecycle: SessionLifecycle::Parked,
576 clients: 0,
577 input_owner: None,
578 awaiting_input: 0,
579 roster: Vec::new(),
580 client_input: None,
581 journal_id: id.0.clone(),
582 locked_by: Some(holder_desc.clone()),
583 pinned: false,
584 busy: false,
585 state_since: None,
586 park_due: None,
587 evict_due: None,
588 title: None,
589 cost_usd: 0.0,
590 messages: 0,
591 };
592 let view = crate::session::view::RuntimeView {
593 model: config.model_override.clone().unwrap_or_else(|| "unknown".into()),
594 thinking_level: "off".into(),
595 reasoning_level: agent_core::reasoning::ReasoningLevel::Off,
596 is_reasoning_explicit: false,
597 thinking_budget: 0,
598 context_window: 200_000,
599 system_prompt: None,
600 compaction_model: "unknown".into(),
601 api_retries: 0,
602 subagent_timeout: 0,
603 max_tool_output: 4096,
604 bash_timeout: 30,
605 bash_max_timeout: 300,
606 prompt_generation: 0,
607 hook_handler_count: 0,
608 prompt_inspection: None,
609 };
610 let (handle, ep) = SessionHandle::new(meta, view);
611 *ep.memory_mode
614 .lock()
615 .unwrap_or_else(std::sync::PoisonError::into_inner) = config.memory_mode;
616 let handle = handle.with_placeholder_config(config.clone());
617 ep.lifecycle.store(SessionLifecycle::Parked as u8, Ordering::Release);
619 let task = tokio::spawn(run(id, ep, config, holder_desc));
620 (handle, task)
621 }
622
623 async fn run(
624 id: SessionId,
625 mut ep: SessionEndpoints,
626 _config: SessionConfig,
627 holder_desc: String,
628 ) {
629 let mut seq: u64 = 0;
630 let emit = |seq: &mut u64, events: &broadcast::Sender<Envelope>, event: SessionEventWire| {
631 let env = Envelope {
632 session_id: id.clone(),
633 seq: *seq,
634 ts: chrono::Utc::now(),
635 event,
636 };
637 *seq += 1;
638 let _ = events.send(env);
639 };
640
641 while let Some(Addressed { cmd, .. }) = ep.cmd_rx.recv().await {
642 match cmd {
643 SessionCommand::Attach { .. } => {
644 let msg = format!(
645 "session {}: journal locked by {} \
646 — close it or use synaps --attach from that process",
647 id.as_str(),
648 holder_desc
649 );
650 emit(&mut seq, &ep.events, SessionEventWire::AttachRefused { message: msg });
651 }
652 SessionCommand::Park => {
653 }
655 SessionCommand::End { reason } => {
656 emit(&mut seq, &ep.events, SessionEventWire::Ended { reason });
657 break;
658 }
659 SessionCommand::Detach { client } => {
660 emit(&mut seq, &ep.events, SessionEventWire::ClientLeft { client });
661 }
662 SessionCommand::Query { id: qid, .. } => {
663 emit(
664 &mut seq,
665 &ep.events,
666 SessionEventWire::QueryResult {
667 id: qid,
668 value: serde_json::json!({
669 "locked_by": holder_desc,
670 "streaming": false,
671 "pending_prompts": 0,
672 }),
673 },
674 );
675 }
676 _ => {
677 emit(
678 &mut seq,
679 &ep.events,
680 SessionEventWire::SystemNotice(format!(
681 "session {}: journal locked by {} — command ignored",
682 id.as_str(),
683 holder_desc
684 )),
685 );
686 }
687 }
688 }
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695 use crate::session::types::*;
696 use crate::{LlmEvent, SessionEvent, StreamEvent};
697
698 async fn next(rx: &mut broadcast::Receiver<Envelope>) -> Envelope {
699 tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
700 .await
701 .expect("timely")
702 .expect("open")
703 }
704
705 #[tokio::test]
706 async fn echo_actor_submit_emits_turn_in_order() {
707 let (handle, task) = SessionHandle::echo_for_test(SessionId::from("echo-1"));
708 let mut rx = handle.subscribe();
709 handle
710 .send(SessionCommand::Submit {
711 text: "hello".into(),
712 attachments: vec![],
713 })
714 .await
715 .unwrap();
716
717 let e0 = next(&mut rx).await;
718 assert!(matches!(
719 e0.event,
720 SessionEventWire::TurnStarted {
721 turn_baseline: 0,
722 trigger: TurnTrigger::User,
723 user_text: None,
724 }
725 ));
726 let e1 = next(&mut rx).await;
727 match e1.event {
728 SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(t))) => assert_eq!(t, "hello"),
729 other => panic!("unexpected {other:?}"),
730 }
731 let e2 = next(&mut rx).await;
732 assert!(matches!(
733 e2.event,
734 SessionEventWire::Stream(StreamEvent::Session(SessionEvent::MessageHistory(_)))
735 ));
736 let e3 = next(&mut rx).await;
737 assert!(matches!(
738 e3.event,
739 SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done))
740 ));
741 let e4 = next(&mut rx).await;
742 match e4.event {
743 SessionEventWire::Conversation(c) => assert_eq!(c.api_messages.len(), 2),
744 other => panic!("unexpected {other:?}"),
745 }
746 assert_eq!(
748 [e0.seq, e1.seq, e2.seq, e3.seq, e4.seq],
749 [0, 1, 2, 3, 4]
750 );
751 assert!(e4.session_id == SessionId::from("echo-1"));
752
753 handle
754 .send(SessionCommand::End {
755 reason: EndReason::ClientQuit,
756 })
757 .await
758 .unwrap();
759 let e5 = next(&mut rx).await;
760 assert!(matches!(
761 e5.event,
762 SessionEventWire::Ended {
763 reason: EndReason::ClientQuit
764 }
765 ));
766 task.await.unwrap();
767 assert!(!handle.is_alive());
768 assert!(matches!(
769 handle.send(SessionCommand::Save).await,
770 Err(TransportError::Closed)
771 ));
772 }
773
774 #[tokio::test]
775 async fn handle_view_swaps_on_set() {
776 let (handle, task) = SessionHandle::echo_for_test(SessionId::from("echo-2"));
777 let mut rx = handle.subscribe();
778 assert_eq!(handle.view().model, "echo");
779 handle
780 .send(SessionCommand::Set {
781 id: 0,
782 setting: SessionSetting::Model { model: "other".into() },
783 })
784 .await
785 .unwrap();
786 let e = next(&mut rx).await;
787 assert!(matches!(e.event, SessionEventWire::SettingChanged(_)));
788 assert_eq!(handle.view().model, "other");
789 drop(handle);
790 task.await.unwrap();
791 }
792}