1use std::sync::atomic::{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, Copy, Debug, Default, PartialEq, Eq)]
17pub struct Presence {
18 pub clients: usize,
19 pub input_owner: Option<ClientId>,
20 pub awaiting_input: usize,
21}
22use super::view::RuntimeView;
23
24pub const CMD_CHAN_CAP: usize = 256;
26pub const DEFAULT_EVENTS_CAP: usize = 1024;
28
29pub fn events_cap() -> usize {
31 std::env::var("SYNAPS_SESSION_EVENTS_CAP")
32 .ok()
33 .and_then(|s| s.parse::<usize>().ok())
34 .map(|n| n.max(16))
35 .unwrap_or(DEFAULT_EVENTS_CAP)
36}
37
38#[derive(Clone)]
39pub struct SessionHandle {
40 pub id: SessionId,
41 cmd_tx: mpsc::Sender<Addressed>,
42 events: broadcast::Sender<Envelope>,
43 meta: Arc<SessionMeta>,
44 view: Arc<arc_swap::ArcSwap<RuntimeView>>,
46 lifecycle: Arc<AtomicU8>,
48 journal_id: Arc<arc_swap::ArcSwap<String>>,
50 presence: Arc<arc_swap::ArcSwap<Presence>>,
52 name: Arc<arc_swap::ArcSwap<Option<String>>>,
55 placeholder_config: Option<Arc<SessionConfig>>,
58}
59
60impl std::fmt::Debug for SessionHandle {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("SessionHandle")
63 .field("id", &self.id)
64 .field("model", &self.meta.model)
65 .finish()
66 }
67}
68
69pub struct SessionEndpoints {
71 pub cmd_rx: mpsc::Receiver<Addressed>,
72 pub events: broadcast::Sender<Envelope>,
73 pub view: Arc<arc_swap::ArcSwap<RuntimeView>>,
74 pub lifecycle: Arc<AtomicU8>,
75 pub journal_id: Arc<arc_swap::ArcSwap<String>>,
76 pub presence: Arc<arc_swap::ArcSwap<Presence>>,
77 pub name: Arc<arc_swap::ArcSwap<Option<String>>>,
78}
79
80impl SessionHandle {
81 pub fn new(meta: SessionMeta, view: RuntimeView) -> (Self, SessionEndpoints) {
84 let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHAN_CAP);
85 let (events, _) = broadcast::channel(events_cap());
86 let view = Arc::new(arc_swap::ArcSwap::from_pointee(view));
87 let lifecycle = Arc::new(AtomicU8::new(SessionLifecycle::Live as u8));
88 let journal_id = Arc::new(arc_swap::ArcSwap::from_pointee(meta.id.0.clone()));
89 let presence = Arc::new(arc_swap::ArcSwap::from_pointee(Presence::default()));
90 let name = Arc::new(arc_swap::ArcSwap::from_pointee(meta.name.clone()));
91 let handle = Self {
92 id: meta.id.clone(),
93 cmd_tx,
94 events: events.clone(),
95 meta: Arc::new(meta),
96 view: Arc::clone(&view),
97 lifecycle: Arc::clone(&lifecycle),
98 journal_id: Arc::clone(&journal_id),
99 presence: Arc::clone(&presence),
100 name: Arc::clone(&name),
101 placeholder_config: None,
102 };
103 (
104 handle,
105 SessionEndpoints {
106 cmd_rx,
107 events,
108 view,
109 lifecycle,
110 journal_id,
111 presence,
112 name,
113 },
114 )
115 }
116
117 pub async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError> {
121 self.send_addressed(Addressed { from: None, cmd })
122 }
123
124 pub async fn send_from(&self, from: ClientId, cmd: SessionCommand) -> Result<(), TransportError> {
127 self.send_addressed(Addressed { from: Some(from), cmd })
128 }
129
130 fn send_addressed(&self, cmd: Addressed) -> Result<(), TransportError> {
131 match self.cmd_tx.try_send(cmd) {
132 Ok(()) => Ok(()),
133 Err(mpsc::error::TrySendError::Full(_)) => Err(TransportError::Backpressure),
134 Err(mpsc::error::TrySendError::Closed(_)) => Err(TransportError::Closed),
135 }
136 }
137
138 pub fn lifecycle(&self) -> SessionLifecycle {
140 SessionLifecycle::from_u8(self.lifecycle.load(Ordering::Acquire))
141 }
142
143 pub fn journal_id(&self) -> String {
145 (**self.journal_id.load()).clone()
146 }
147
148 pub fn presence(&self) -> Presence {
150 **self.presence.load()
151 }
152
153 pub fn name(&self) -> Option<String> {
155 (**self.name.load()).clone()
156 }
157
158 pub fn meta_live(&self) -> SessionMeta {
161 let mut m = (*self.meta).clone();
162 let p = self.presence();
163 m.lifecycle = self.lifecycle();
164 m.clients = p.clients;
165 m.input_owner = p.input_owner;
166 m.awaiting_input = p.awaiting_input;
167 m.journal_id = self.journal_id();
168 m.name = self.name();
169 m
170 }
171
172 pub fn subscribe(&self) -> broadcast::Receiver<Envelope> {
173 self.events.subscribe()
174 }
175
176 pub fn placeholder_config(&self) -> Option<&SessionConfig> {
178 self.placeholder_config.as_deref()
179 }
180
181 pub(crate) fn with_placeholder_config(mut self, cfg: SessionConfig) -> Self {
182 self.placeholder_config = Some(Arc::new(cfg));
183 self
184 }
185
186 pub fn meta(&self) -> &SessionMeta {
187 &self.meta
188 }
189
190 pub fn view(&self) -> arc_swap::Guard<Arc<RuntimeView>> {
191 self.view.load()
192 }
193
194 pub fn is_alive(&self) -> bool {
196 !self.cmd_tx.is_closed()
197 }
198
199 pub async fn closed(&self) {
201 self.cmd_tx.closed().await
202 }
203
204 #[cfg(any(test, feature = "testing"))]
207 pub fn echo_for_test(id: SessionId) -> (Self, tokio::task::JoinHandle<()>) {
208 echo::spawn(id)
209 }
210}
211
212#[cfg(any(test, feature = "testing"))]
213pub mod echo {
214 use std::collections::HashMap;
219
220 use agent_core::reasoning::ReasoningLevel;
221
222 use super::*;
223 use crate::session::types::*;
224 use crate::{LlmEvent, SessionEvent, StreamEvent};
225
226 pub fn meta_for(id: &SessionId) -> SessionMeta {
227 SessionMeta {
228 id: id.clone(),
229 name: None,
230 model: "echo".into(),
231 cwd: None,
232 created_at: chrono::Utc::now(),
233 continued: false,
234 continue_info: None,
235 host_pid: std::process::id(),
236 lifecycle: SessionLifecycle::Live,
237 clients: 0,
238 input_owner: None,
239 awaiting_input: 0,
240 journal_id: id.0.clone(),
241 locked_by: None,
242 }
243 }
244
245 pub fn view_for() -> RuntimeView {
246 RuntimeView {
247 model: "echo".into(),
248 thinking_level: "off".into(),
249 reasoning_level: ReasoningLevel::Off,
250 is_reasoning_explicit: false,
251 thinking_budget: 0,
252 context_window: 200_000,
253 system_prompt: None,
254 compaction_model: "echo".into(),
255 api_retries: 0,
256 subagent_timeout: 0,
257 max_tool_output: 4096,
258 bash_timeout: 30,
259 bash_max_timeout: 300,
260 prompt_generation: 0,
261 hook_handler_count: 0,
262 prompt_inspection: None,
263 }
264 }
265
266 pub fn spawn(id: SessionId) -> (SessionHandle, tokio::task::JoinHandle<()>) {
267 let (handle, ep) = SessionHandle::new(meta_for(&id), view_for());
268 let task = tokio::spawn(run(id, ep));
269 (handle, task)
270 }
271
272 struct Echo {
273 id: SessionId,
274 events: broadcast::Sender<Envelope>,
275 view: Arc<arc_swap::ArcSwap<RuntimeView>>,
276 seq: u64,
277 next_client: u64,
278 clients: HashMap<ClientId, ClientMeta>,
279 conv: ConversationSnapshot,
280 }
281
282 impl Echo {
283 fn emit(&mut self, event: SessionEventWire) {
284 let env = Envelope {
285 session_id: self.id.clone(),
286 seq: self.seq,
287 ts: chrono::Utc::now(),
288 event,
289 };
290 self.seq += 1;
291 let _ = self.events.send(env);
293 }
294
295 fn conv_snapshot(&self) -> ConversationSnapshot {
296 let mut c = self.conv.clone();
297 c.messages_len = c.api_messages.len();
298 c
299 }
300
301 fn snapshot_for(&self, client: &ClientMeta) -> AttachSnapshot {
304 let mut conversation = self.conv_snapshot();
305 let display_tail = (client.history == HistoryMode::Digest).then(|| {
306 let t = crate::session::display::display_tail(&conversation.api_messages, client.tail_items);
307 conversation.api_messages = Vec::new();
308 t
309 });
310 AttachSnapshot {
311 meta: meta_for(&self.id),
312 view: (**self.view.load()).clone(),
313 conversation,
314 streaming: false,
315 replay: Vec::new(),
316 pending_prompts: Vec::new(),
317 clients: self.clients.iter().map(|(c, m)| (*c, m.kind)).collect(),
318 input_owner: None,
319 display_tail,
320 }
321 }
322
323 fn handle(&mut self, cmd: SessionCommand) -> bool {
324 match cmd {
325 SessionCommand::Attach { client, mode } => {
326 let cid = ClientId(self.next_client);
327 self.next_client += 1;
328 let kind = client.kind;
329 let snapshot_meta = client.clone();
330 self.clients.insert(cid, client);
331 if mode != AttachMode::Mirror {
332 self.emit(SessionEventWire::SystemNotice(format!(
333 "attach mode {mode:?} not supported yet; using mirror"
334 )));
335 }
336 let snapshot = self.snapshot_for(&snapshot_meta);
337 self.emit(SessionEventWire::Attached {
338 client: cid,
339 snapshot,
340 });
341 self.emit(SessionEventWire::ClientJoined { client: cid, kind });
342 }
343 SessionCommand::Detach { client } => {
344 if self.clients.remove(&client).is_some() {
345 self.emit(SessionEventWire::ClientLeft { client });
346 }
347 }
348 SessionCommand::Submit { text, .. } => {
349 let baseline = self.conv.api_messages.len();
350 self.conv.api_messages.push(Arc::new(serde_json::json!({
351 "role": "user", "content": text
352 })));
353 self.emit(SessionEventWire::TurnStarted {
354 turn_baseline: baseline,
355 trigger: TurnTrigger::User,
356 user_text: None,
357 });
358 self.emit(SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(
359 text.clone(),
360 ))));
361 self.conv.api_messages.push(Arc::new(serde_json::json!({
362 "role": "assistant", "content": text
363 })));
364 self.emit(SessionEventWire::Stream(StreamEvent::Session(
365 SessionEvent::MessageHistory(self.conv.api_messages.clone()),
366 )));
367 self.emit(SessionEventWire::Stream(StreamEvent::Session(
368 SessionEvent::Done,
369 )));
370 let snap = self.conv_snapshot();
371 self.emit(SessionEventWire::Conversation(snap));
372 }
373 SessionCommand::Steer { text } => {
374 self.conv.queued_message = Some(text.clone());
375 self.emit(SessionEventWire::Steered {
376 text,
377 delivered: false,
378 });
379 }
380 SessionCommand::Cancel => {
381 let snap = self.conv_snapshot();
382 self.emit(SessionEventWire::Conversation(snap));
383 }
384 SessionCommand::Query { id, query } => {
385 let value = match query {
386 SessionQuery::View => {
387 serde_json::to_value(&**self.view.load()).unwrap_or_default()
388 }
389 SessionQuery::Messages => {
390 serde_json::to_value(&self.conv.api_messages).unwrap_or_default()
391 }
392 SessionQuery::DisplayTail { items } => serde_json::to_value(
393 super::super::display::display_tail(&self.conv.api_messages, items),
394 )
395 .unwrap_or_default(),
396 other => serde_json::json!({ "unsupported": format!("{other:?}") }),
397 };
398 self.emit(SessionEventWire::QueryResult { id, value });
399 }
400 SessionCommand::Set { id, setting } => {
401 let mut view = (**self.view.load()).clone();
402 if let SessionSetting::Model { model } = &setting {
403 view.model = model.clone();
404 }
405 self.view.store(Arc::new(view.clone()));
406 self.emit(SessionEventWire::SettingChanged(SettingApplied {
407 id,
408 setting: "echo".into(),
409 ok: true,
410 message: None,
411 view,
412 clamp: None,
413 }));
414 }
415 SessionCommand::End { reason } => {
416 self.emit(SessionEventWire::Ended { reason });
417 return false;
418 }
419 other => {
420 self.emit(SessionEventWire::SystemNotice(format!("echo: {other:?}")));
421 }
422 }
423 true
424 }
425 }
426
427 async fn run(id: SessionId, mut ep: SessionEndpoints) {
428 let mut echo = Echo {
429 id,
430 events: ep.events,
431 view: ep.view,
432 seq: 0,
433 next_client: 1,
434 clients: HashMap::new(),
435 conv: ConversationSnapshot::default(),
436 };
437 while let Some(Addressed { cmd, .. }) = ep.cmd_rx.recv().await {
438 if !echo.handle(cmd) {
439 break;
440 }
441 }
442 }
443}
444
445pub mod locked_placeholder {
449 use std::sync::atomic::Ordering;
450
451 use super::*;
452 use crate::session::types::*;
453
454
455 pub fn spawn(
460 id: SessionId,
461 config: SessionConfig,
462 holder_desc: String,
463 ) -> (SessionHandle, tokio::task::JoinHandle<()>) {
464 let meta = SessionMeta {
465 id: id.clone(),
466 name: config.name.clone(),
467 model: config.model_override.clone().unwrap_or_else(|| "unknown".into()),
468 cwd: config.cwd.clone(),
469 created_at: chrono::Utc::now(),
470 continued: true,
471 continue_info: None,
472 host_pid: std::process::id(),
473 lifecycle: SessionLifecycle::Parked,
474 clients: 0,
475 input_owner: None,
476 awaiting_input: 0,
477 journal_id: id.0.clone(),
478 locked_by: Some(holder_desc.clone()),
479 };
480 let view = crate::session::view::RuntimeView {
481 model: config.model_override.clone().unwrap_or_else(|| "unknown".into()),
482 thinking_level: "off".into(),
483 reasoning_level: agent_core::reasoning::ReasoningLevel::Off,
484 is_reasoning_explicit: false,
485 thinking_budget: 0,
486 context_window: 200_000,
487 system_prompt: None,
488 compaction_model: "unknown".into(),
489 api_retries: 0,
490 subagent_timeout: 0,
491 max_tool_output: 4096,
492 bash_timeout: 30,
493 bash_max_timeout: 300,
494 prompt_generation: 0,
495 hook_handler_count: 0,
496 prompt_inspection: None,
497 };
498 let (handle, ep) = SessionHandle::new(meta, view);
499 let handle = handle.with_placeholder_config(config.clone());
500 ep.lifecycle.store(SessionLifecycle::Parked as u8, Ordering::Release);
502 let task = tokio::spawn(run(id, ep, config, holder_desc));
503 (handle, task)
504 }
505
506 async fn run(
507 id: SessionId,
508 mut ep: SessionEndpoints,
509 _config: SessionConfig,
510 holder_desc: String,
511 ) {
512 let mut seq: u64 = 0;
513 let emit = |seq: &mut u64, events: &broadcast::Sender<Envelope>, event: SessionEventWire| {
514 let env = Envelope {
515 session_id: id.clone(),
516 seq: *seq,
517 ts: chrono::Utc::now(),
518 event,
519 };
520 *seq += 1;
521 let _ = events.send(env);
522 };
523
524 while let Some(Addressed { cmd, .. }) = ep.cmd_rx.recv().await {
525 match cmd {
526 SessionCommand::Attach { .. } => {
527 let msg = format!(
528 "session {}: journal locked by {} \
529 — close it or use synaps --attach from that process",
530 id.as_str(),
531 holder_desc
532 );
533 emit(&mut seq, &ep.events, SessionEventWire::AttachRefused { message: msg });
534 }
535 SessionCommand::Park => {
536 }
538 SessionCommand::End { reason } => {
539 emit(&mut seq, &ep.events, SessionEventWire::Ended { reason });
540 break;
541 }
542 SessionCommand::Detach { client } => {
543 emit(&mut seq, &ep.events, SessionEventWire::ClientLeft { client });
544 }
545 SessionCommand::Query { id: qid, .. } => {
546 emit(
547 &mut seq,
548 &ep.events,
549 SessionEventWire::QueryResult {
550 id: qid,
551 value: serde_json::json!({
552 "locked_by": holder_desc,
553 "streaming": false,
554 "pending_prompts": 0,
555 }),
556 },
557 );
558 }
559 _ => {
560 emit(
561 &mut seq,
562 &ep.events,
563 SessionEventWire::SystemNotice(format!(
564 "session {}: journal locked by {} — command ignored",
565 id.as_str(),
566 holder_desc
567 )),
568 );
569 }
570 }
571 }
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use crate::session::types::*;
579 use crate::{LlmEvent, SessionEvent, StreamEvent};
580
581 async fn next(rx: &mut broadcast::Receiver<Envelope>) -> Envelope {
582 tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
583 .await
584 .expect("timely")
585 .expect("open")
586 }
587
588 #[tokio::test]
589 async fn echo_actor_submit_emits_turn_in_order() {
590 let (handle, task) = SessionHandle::echo_for_test(SessionId::from("echo-1"));
591 let mut rx = handle.subscribe();
592 handle
593 .send(SessionCommand::Submit {
594 text: "hello".into(),
595 attachments: vec![],
596 })
597 .await
598 .unwrap();
599
600 let e0 = next(&mut rx).await;
601 assert!(matches!(
602 e0.event,
603 SessionEventWire::TurnStarted {
604 turn_baseline: 0,
605 trigger: TurnTrigger::User,
606 user_text: None,
607 }
608 ));
609 let e1 = next(&mut rx).await;
610 match e1.event {
611 SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(t))) => assert_eq!(t, "hello"),
612 other => panic!("unexpected {other:?}"),
613 }
614 let e2 = next(&mut rx).await;
615 assert!(matches!(
616 e2.event,
617 SessionEventWire::Stream(StreamEvent::Session(SessionEvent::MessageHistory(_)))
618 ));
619 let e3 = next(&mut rx).await;
620 assert!(matches!(
621 e3.event,
622 SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done))
623 ));
624 let e4 = next(&mut rx).await;
625 match e4.event {
626 SessionEventWire::Conversation(c) => assert_eq!(c.api_messages.len(), 2),
627 other => panic!("unexpected {other:?}"),
628 }
629 assert_eq!(
631 [e0.seq, e1.seq, e2.seq, e3.seq, e4.seq],
632 [0, 1, 2, 3, 4]
633 );
634 assert!(e4.session_id == SessionId::from("echo-1"));
635
636 handle
637 .send(SessionCommand::End {
638 reason: EndReason::ClientQuit,
639 })
640 .await
641 .unwrap();
642 let e5 = next(&mut rx).await;
643 assert!(matches!(
644 e5.event,
645 SessionEventWire::Ended {
646 reason: EndReason::ClientQuit
647 }
648 ));
649 task.await.unwrap();
650 assert!(!handle.is_alive());
651 assert!(matches!(
652 handle.send(SessionCommand::Save).await,
653 Err(TransportError::Closed)
654 ));
655 }
656
657 #[tokio::test]
658 async fn handle_view_swaps_on_set() {
659 let (handle, task) = SessionHandle::echo_for_test(SessionId::from("echo-2"));
660 let mut rx = handle.subscribe();
661 assert_eq!(handle.view().model, "echo");
662 handle
663 .send(SessionCommand::Set {
664 id: 0,
665 setting: SessionSetting::Model { model: "other".into() },
666 })
667 .await
668 .unwrap();
669 let e = next(&mut rx).await;
670 assert!(matches!(e.event, SessionEventWire::SettingChanged(_)));
671 assert_eq!(handle.view().model, "other");
672 drop(handle);
673 task.await.unwrap();
674 }
675}