Skip to main content

atman_runtime/
session.rs

1use std::path::{Path, PathBuf};
2use std::sync::Mutex;
3
4use tokio::sync::{broadcast, watch};
5use tokio_util::sync::CancellationToken;
6use uuid::Uuid;
7
8use crate::event::{Event, EventSink, FlowRunId, TurnId};
9use crate::event_log::reader::{find_last_seq, replay_context_snapshot_from};
10use crate::event_writer::EventWriter;
11use crate::injection::{Injection, InjectionId, InjectionState};
12use crate::message::{Message, MessageRole};
13use crate::projection::message_window::{
14    TranscriptEntry, replay_all_messages_with_seq, replay_messages_from, replay_messages_with_seq,
15    replay_transcript_from,
16};
17use crate::stream::StreamFrame;
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub struct SessionId(pub Uuid);
21
22impl SessionId {
23    pub fn now() -> Self {
24        Self(Uuid::new_v4())
25    }
26
27    pub fn parse(s: &str) -> Result<Self, uuid::Error> {
28        Uuid::parse_str(s).map(Self)
29    }
30}
31
32impl std::fmt::Display for SessionId {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        self.0.fmt(f)
35    }
36}
37
38type WatchKeepalive = (
39    watch::Receiver<ContextSnapshot>,
40    watch::Receiver<Option<String>>,
41    watch::Receiver<usize>,
42    watch::Receiver<Vec<crate::memory::todo::Todo>>,
43    watch::Receiver<Vec<crate::memory::plan::Plan>>,
44);
45
46#[derive(Debug)]
47pub struct TurnState {
48    pub current_turn: Mutex<Option<TurnId>>,
49    pub flow_cancel: Mutex<CancellationToken>,
50    pub streamed: std::sync::atomic::AtomicBool,
51}
52
53impl TurnState {
54    fn new() -> Self {
55        Self {
56            current_turn: Mutex::new(None),
57            flow_cancel: Mutex::new(CancellationToken::new()),
58            streamed: std::sync::atomic::AtomicBool::new(false),
59        }
60    }
61}
62
63pub struct WatchHub {
64    pub stream_tx: broadcast::Sender<StreamFrame>,
65    pub context: watch::Sender<ContextSnapshot>,
66    pub goal: watch::Sender<Option<String>>,
67    pub attach: watch::Sender<usize>,
68    pub todos: watch::Sender<Vec<crate::memory::todo::Todo>>,
69    pub plans: watch::Sender<Vec<crate::memory::plan::Plan>>,
70    _keepalive: WatchKeepalive,
71}
72
73pub struct CompactionState {
74    pub manual_pending: std::sync::atomic::AtomicBool,
75    pub last_input_tokens: std::sync::atomic::AtomicU64,
76    pub review_mode: Mutex<CompactReviewMode>,
77    pub lock: std::sync::Arc<tokio::sync::Mutex<()>>,
78}
79
80impl CompactionState {
81    fn new() -> Self {
82        Self {
83            manual_pending: std::sync::atomic::AtomicBool::new(false),
84            last_input_tokens: std::sync::atomic::AtomicU64::new(0),
85            review_mode: Mutex::new(CompactReviewMode::default()),
86            lock: std::sync::Arc::new(tokio::sync::Mutex::new(())),
87        }
88    }
89}
90
91pub struct InteractionServices {
92    pub approval: std::sync::Arc<ApprovalRegistry>,
93    pub compact_reviews: std::sync::Arc<CompactReviewRegistry>,
94    pub forms: std::sync::Arc<FormRegistry>,
95}
96
97impl InteractionServices {
98    fn new() -> Self {
99        Self {
100            approval: std::sync::Arc::new(ApprovalRegistry::new()),
101            compact_reviews: std::sync::Arc::new(CompactReviewRegistry::new()),
102            forms: std::sync::Arc::new(FormRegistry::new()),
103        }
104    }
105}
106
107pub struct Session {
108    id: SessionId,
109    dir: PathBuf,
110    writer: std::sync::Mutex<Option<EventWriter>>,
111    sink: EventSink,
112    message_stream: crate::message_stream::MessageStream,
113    messages: std::sync::Arc<std::sync::Mutex<Vec<Message>>>,
114    pub turn: TurnState,
115    pub watch: WatchHub,
116    pub compaction: CompactionState,
117    pub interactions: InteractionServices,
118    injection_queue: Mutex<Vec<Injection>>,
119    injection_tx: broadcast::Sender<Injection>,
120    last_image_user_msg: Mutex<Option<LastImageUserMsg>>,
121    read_files: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<std::path::PathBuf>>>,
122    fs_access_mode: Mutex<Option<crate::fs_access::FsAccessMode>>,
123    project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
124}
125
126#[derive(Debug, Clone)]
127pub struct PendingCompactReview {
128    pub review_id: String,
129    pub summary: String,
130    pub slice_preview: String,
131    pub slice_count: usize,
132    pub range_start: usize,
133    pub range_end: usize,
134    pub tokens_before: u64,
135    pub emitted_at: chrono::DateTime<chrono::Utc>,
136}
137
138#[derive(Debug, Clone)]
139pub enum CompactReviewDecision {
140    AcceptAsIs,
141    AcceptEdited { summary: String },
142    Reject,
143}
144
145pub struct CompactReviewRegistry {
146    entry: std::sync::Mutex<Option<CompactReviewEntry>>,
147    watch_tx: watch::Sender<Option<PendingCompactReview>>,
148}
149
150struct CompactReviewEntry {
151    pending: PendingCompactReview,
152    responder: tokio::sync::oneshot::Sender<CompactReviewDecision>,
153}
154
155impl Default for CompactReviewRegistry {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161impl CompactReviewRegistry {
162    pub fn new() -> Self {
163        let (watch_tx, _) = watch::channel(None);
164        Self {
165            entry: std::sync::Mutex::new(None),
166            watch_tx,
167        }
168    }
169
170    pub fn subscribe(&self) -> watch::Receiver<Option<PendingCompactReview>> {
171        self.watch_tx.subscribe()
172    }
173
174    pub fn list_pending(&self) -> Option<PendingCompactReview> {
175        self.entry
176            .lock()
177            .unwrap()
178            .as_ref()
179            .map(|e| e.pending.clone())
180    }
181
182    pub fn subscriber_count(&self) -> usize {
183        self.watch_tx.receiver_count()
184    }
185
186    pub fn request(
187        &self,
188        pending: PendingCompactReview,
189    ) -> tokio::sync::oneshot::Receiver<CompactReviewDecision> {
190        let (tx, rx) = tokio::sync::oneshot::channel();
191        if self.watch_tx.receiver_count() == 0 {
192            let _ = tx.send(CompactReviewDecision::AcceptAsIs);
193            return rx;
194        }
195        {
196            let mut slot = self.entry.lock().unwrap();
197            if let Some(prev) = slot.take() {
198                let _ = prev.responder.send(CompactReviewDecision::Reject);
199            }
200            *slot = Some(CompactReviewEntry {
201                pending: pending.clone(),
202                responder: tx,
203            });
204        }
205        let _ = self.watch_tx.send(Some(pending));
206        rx
207    }
208
209    pub fn decide(&self, review_id: &str, decision: CompactReviewDecision) -> bool {
210        let entry = {
211            let mut slot = self.entry.lock().unwrap();
212            match slot.as_ref() {
213                Some(e) if e.pending.review_id == review_id => slot.take(),
214                _ => None,
215            }
216        };
217        match entry {
218            Some(e) => {
219                let _ = e.responder.send(decision);
220                let _ = self.watch_tx.send(None);
221                true
222            }
223            None => false,
224        }
225    }
226}
227
228#[derive(Debug, Clone)]
229pub struct PendingApproval {
230    pub tool_use_id: String,
231    pub tool_name: String,
232    pub args_preview: String,
233    pub preview: Option<String>,
234    pub level: crate::tool::ApprovalLevel,
235    pub run_id: FlowRunId,
236    pub emitted_at: chrono::DateTime<chrono::Utc>,
237    pub bypass_auto_ceiling: bool,
238}
239
240#[derive(Debug, Clone)]
241pub enum ApprovalDecision {
242    Approve,
243    Deny { reason: String },
244}
245
246pub struct FormRegistry {
247    entries: std::sync::Mutex<Vec<FormEntry>>,
248    watch_tx: watch::Sender<Vec<crate::form::PendingForm>>,
249}
250
251struct FormEntry {
252    pending: crate::form::PendingForm,
253    responder: tokio::sync::oneshot::Sender<crate::form::FormAnswer>,
254}
255
256impl Default for FormRegistry {
257    fn default() -> Self {
258        Self::new()
259    }
260}
261
262impl FormRegistry {
263    pub fn new() -> Self {
264        let (watch_tx, _) = watch::channel(Vec::new());
265        Self {
266            entries: std::sync::Mutex::new(Vec::new()),
267            watch_tx,
268        }
269    }
270
271    pub fn subscribe(&self) -> watch::Receiver<Vec<crate::form::PendingForm>> {
272        self.watch_tx.subscribe()
273    }
274
275    pub fn list_pending(&self) -> Vec<crate::form::PendingForm> {
276        self.entries
277            .lock()
278            .unwrap()
279            .iter()
280            .map(|e| e.pending.clone())
281            .collect()
282    }
283
284    pub fn subscriber_count(&self) -> usize {
285        self.watch_tx.receiver_count()
286    }
287
288    // No TUI attached → auto-cancel so flows don't hang forever. Otherwise
289    // enqueue and hand a receiver back to the caller.
290    pub fn request(
291        &self,
292        pending: crate::form::PendingForm,
293    ) -> tokio::sync::oneshot::Receiver<crate::form::FormAnswer> {
294        let (tx, rx) = tokio::sync::oneshot::channel();
295        if self.watch_tx.receiver_count() == 0 {
296            let _ = tx.send(crate::form::FormAnswer::Cancelled);
297            return rx;
298        }
299        {
300            let mut entries = self.entries.lock().unwrap();
301            entries.push(FormEntry {
302                pending: pending.clone(),
303                responder: tx,
304            });
305        }
306        self.broadcast_snapshot();
307        rx
308    }
309
310    pub fn submit(&self, form_id: &str, answer: crate::form::FormAnswer) -> bool {
311        let entry = {
312            let mut entries = self.entries.lock().unwrap();
313            let pos = entries.iter().position(|e| e.pending.form_id == form_id);
314            pos.map(|p| entries.remove(p))
315        };
316        match entry {
317            Some(e) => {
318                let _ = e.responder.send(answer);
319                self.broadcast_snapshot();
320                true
321            }
322            None => false,
323        }
324    }
325
326    pub fn cancel_all(&self) {
327        let drained: Vec<FormEntry> = {
328            let mut entries = self.entries.lock().unwrap();
329            std::mem::take(&mut *entries)
330        };
331        for e in drained {
332            let _ = e.responder.send(crate::form::FormAnswer::Cancelled);
333        }
334        self.broadcast_snapshot();
335    }
336
337    pub fn promote(&self, form_id: &str) {
338        let mut entries = self.entries.lock().unwrap();
339        if let Some(pos) = entries.iter().position(|e| e.pending.form_id == form_id) {
340            if pos == 0 {
341                return;
342            }
343            let entry = entries.remove(pos);
344            entries.insert(0, entry);
345        }
346        drop(entries);
347        self.broadcast_snapshot();
348    }
349
350    fn broadcast_snapshot(&self) {
351        let snap = self
352            .entries
353            .lock()
354            .unwrap()
355            .iter()
356            .map(|e| e.pending.clone())
357            .collect();
358        let _ = self.watch_tx.send(snap);
359    }
360}
361
362pub struct ApprovalRegistry {
363    entries: std::sync::Mutex<Vec<ApprovalEntry>>,
364    auto_ceiling: std::sync::Mutex<crate::tool::ApprovalLevel>,
365    watch_tx: watch::Sender<Vec<PendingApproval>>,
366}
367
368struct ApprovalEntry {
369    pending: PendingApproval,
370    responder: tokio::sync::oneshot::Sender<ApprovalDecision>,
371}
372
373impl Default for ApprovalRegistry {
374    fn default() -> Self {
375        Self::new()
376    }
377}
378
379impl ApprovalRegistry {
380    pub fn new() -> Self {
381        let (watch_tx, _) = watch::channel(Vec::new());
382        Self {
383            entries: std::sync::Mutex::new(Vec::new()),
384            auto_ceiling: std::sync::Mutex::new(crate::tool::ApprovalLevel::Approve),
385            watch_tx,
386        }
387    }
388
389    pub fn subscribe(&self) -> watch::Receiver<Vec<PendingApproval>> {
390        self.watch_tx.subscribe()
391    }
392
393    pub fn list_pending(&self) -> Vec<PendingApproval> {
394        self.entries
395            .lock()
396            .unwrap()
397            .iter()
398            .map(|e| e.pending.clone())
399            .collect()
400    }
401
402    pub fn set_auto_ceiling(&self, level: crate::tool::ApprovalLevel) {
403        *self.auto_ceiling.lock().unwrap() = level;
404    }
405
406    pub fn request(
407        &self,
408        pending: PendingApproval,
409    ) -> tokio::sync::oneshot::Receiver<ApprovalDecision> {
410        let (tx, rx) = tokio::sync::oneshot::channel();
411        if !pending.bypass_auto_ceiling && pending.level <= *self.auto_ceiling.lock().unwrap() {
412            let _ = tx.send(ApprovalDecision::Approve);
413            return rx;
414        }
415        {
416            let mut entries = self.entries.lock().unwrap();
417            entries.push(ApprovalEntry {
418                pending,
419                responder: tx,
420            });
421        }
422        self.broadcast_snapshot();
423        rx
424    }
425
426    pub fn decide(&self, tool_use_id: &str, decision: ApprovalDecision) -> bool {
427        let mut entries = self.entries.lock().unwrap();
428        if let Some(pos) = entries
429            .iter()
430            .position(|e| e.pending.tool_use_id == tool_use_id)
431        {
432            let entry = entries.remove(pos);
433            let _ = entry.responder.send(decision);
434            drop(entries);
435            self.broadcast_snapshot();
436            true
437        } else {
438            false
439        }
440    }
441
442    pub fn decide_all(&self, decision: ApprovalDecision) -> usize {
443        let mut entries = self.entries.lock().unwrap();
444        let count = entries.len();
445        for entry in entries.drain(..) {
446            let _ = entry.responder.send(decision.clone());
447        }
448        drop(entries);
449        self.broadcast_snapshot();
450        count
451    }
452
453    fn broadcast_snapshot(&self) {
454        let snapshot = self
455            .entries
456            .lock()
457            .unwrap()
458            .iter()
459            .map(|e| e.pending.clone())
460            .collect();
461        let _ = self.watch_tx.send(snapshot);
462    }
463}
464type ImagePart = (usize, String);
465
466#[derive(Debug, Clone)]
467struct LastImageUserMsg {
468    message_seq: u64,
469    images: Vec<ImagePart>,
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
473pub enum CompactReviewMode {
474    Always,
475    #[default]
476    ManualOnly,
477    Never,
478}
479
480impl CompactReviewMode {
481    pub fn parse(s: &str) -> Option<Self> {
482        match s.trim() {
483            "always" => Some(Self::Always),
484            "manual-only" | "manual_only" => Some(Self::ManualOnly),
485            "never" => Some(Self::Never),
486            _ => None,
487        }
488    }
489
490    pub fn should_review(self, forced: bool) -> bool {
491        match self {
492            Self::Always => true,
493            Self::ManualOnly => forced,
494            Self::Never => false,
495        }
496    }
497}
498
499#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct CompactResult {
501    pub before_tokens: u64,
502    pub after_tokens: u64,
503    pub compacted_start: usize,
504    pub compacted_end: usize,
505}
506
507#[derive(Debug, Clone, Default, PartialEq)]
508pub struct ContextSnapshot {
509    pub model: String,
510    pub tokens_in: u64,
511    pub tokens_out: u64,
512    pub cost_usd: f64,
513    pub mcp_servers: Vec<crate::mcp::McpServerStatus>,
514    pub memory_recent_count: u16,
515    pub window_tokens: u64,
516    pub window_budget: u64,
517    pub cache_read: u64,
518    pub cache_write: u64,
519    pub last_ttft_ms: u64,
520    pub last_tokens_per_sec: f64,
521}
522
523#[derive(Debug, thiserror::Error)]
524pub enum SessionOpenError {
525    #[error("invalid session id `{sid}` (want a UUID)")]
526    InvalidId { sid: String },
527    #[error("session `{sid}` not found at {}", dir.display())]
528    NotFound { sid: String, dir: PathBuf },
529    #[error("session writer init: {0}")]
530    WriterInit(#[source] std::io::Error),
531    #[error("replay {}: {source}", path.display())]
532    Replay {
533        path: PathBuf,
534        #[source]
535        source: std::io::Error,
536    },
537}
538
539fn load_goal(dir: &Path) -> Option<String> {
540    if dir.as_os_str().is_empty() {
541        return None;
542    }
543    let store = crate::memory::goal::GoalStore::at(dir);
544    match store.get() {
545        Ok(s) if !s.is_empty() => Some(s),
546        _ => None,
547    }
548}
549
550#[derive(serde::Serialize, serde::Deserialize, Default)]
551struct PersistedContextState {
552    #[serde(default)]
553    model: String,
554    #[serde(default)]
555    window_tokens: u64,
556    #[serde(default)]
557    window_budget: u64,
558}
559
560impl PersistedContextState {
561    fn path(dir: &Path) -> PathBuf {
562        dir.join("context_state.json")
563    }
564
565    fn load(dir: &Path) -> Self {
566        match std::fs::read_to_string(Self::path(dir)) {
567            Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
568            Err(_) => Self::default(),
569        }
570    }
571
572    fn save(&self, dir: &Path) {
573        if let Ok(json) = serde_json::to_string_pretty(self) {
574            let _ = std::fs::write(Self::path(dir), &json);
575        }
576    }
577}
578
579fn default_project_index(root: &Path) -> Option<std::sync::Arc<crate::index::AnchorIndex>> {
580    match crate::index::AnchorIndex::open_project(root) {
581        Ok(idx) => Some(std::sync::Arc::new(idx)),
582        Err(e) => {
583            crate::notify!(
584                warn,
585                "project index unavailable at {} — history search disabled: {e}",
586                root.display()
587            );
588            None
589        }
590    }
591}
592
593impl Session {
594    pub fn open(root: impl AsRef<Path>) -> std::io::Result<Self> {
595        Self::open_with_redactor(root, None)
596    }
597
598    pub fn open_with_redactor(
599        root: impl AsRef<Path>,
600        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
601    ) -> std::io::Result<Self> {
602        let root_ref = root.as_ref();
603        let project_index = default_project_index(root_ref);
604        Self::open_with_context(root_ref, redactor, project_index)
605    }
606
607    pub fn open_with_context(
608        root: impl AsRef<Path>,
609        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
610        project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
611    ) -> std::io::Result<Self> {
612        let id = SessionId::now();
613        let dir = root.as_ref().join("sessions").join(id.to_string());
614        if let Some(ls) = crate::notify::log_sink() {
615            ls.set_session_id(Some(id.to_string()));
616        }
617        let writer = EventWriter::spawn_full(
618            &dir,
619            redactor.clone(),
620            project_index.clone(),
621            Some(id.to_string()),
622        )?;
623        if let Err(e) = crate::session_meta::SessionMeta::from_cwd().save(&dir) {
624            crate::notify!(error, "session meta write failed: {e}");
625        }
626        let mut sink = EventSink::new().with_forwarder(writer.sender());
627        if let Some(r) = redactor {
628            sink = sink.with_redactor(r);
629        }
630        let (injection_tx, _) = broadcast::channel(32);
631        let (stream_tx, _) = broadcast::channel(2048);
632        let (context_watch, context_rx) = watch::channel(ContextSnapshot::default());
633        let (goal_watch, goal_rx) = watch::channel(None);
634        let (attach_watch, attach_rx) = watch::channel(0);
635        let (todos_watch, todos_rx) = watch::channel(Vec::new());
636        let (plans_watch, plans_rx) = watch::channel(Vec::new());
637        let events_handle = sink.events_handle();
638        Ok(Self {
639            id,
640            dir,
641            writer: std::sync::Mutex::new(Some(writer)),
642            sink,
643            message_stream: crate::message_stream::MessageStream::new(events_handle),
644            messages: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
645            turn: TurnState::new(),
646            watch: WatchHub {
647                stream_tx,
648                context: context_watch,
649                goal: goal_watch,
650                attach: attach_watch,
651                todos: todos_watch,
652                plans: plans_watch,
653                _keepalive: (context_rx, goal_rx, attach_rx, todos_rx, plans_rx),
654            },
655            compaction: CompactionState::new(),
656            interactions: InteractionServices::new(),
657            injection_queue: Mutex::new(Vec::new()),
658            injection_tx,
659            last_image_user_msg: Mutex::new(None),
660            read_files: std::sync::Arc::new(
661                std::sync::Mutex::new(std::collections::HashSet::new()),
662            ),
663            fs_access_mode: Mutex::new(None),
664            project_index,
665        })
666    }
667
668    pub fn open_existing(root: impl AsRef<Path>, sid: &str) -> Result<Self, SessionOpenError> {
669        Self::open_existing_with_redactor(root, sid, None)
670    }
671
672    pub fn open_existing_with_redactor(
673        root: impl AsRef<Path>,
674        sid: &str,
675        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
676    ) -> Result<Self, SessionOpenError> {
677        let project_index = default_project_index(root.as_ref());
678        Self::open_existing_with_context(root, sid, redactor, project_index)
679    }
680
681    pub fn open_existing_with_context(
682        root: impl AsRef<Path>,
683        sid: &str,
684        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
685        project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
686    ) -> Result<Self, SessionOpenError> {
687        let id = SessionId::parse(sid).map_err(|_| SessionOpenError::InvalidId {
688            sid: sid.to_string(),
689        })?;
690        let dir = root.as_ref().join("sessions").join(id.to_string());
691        if let Some(ls) = crate::notify::log_sink() {
692            ls.set_session_id(Some(id.to_string()));
693        }
694        if !dir.exists() {
695            return Err(SessionOpenError::NotFound {
696                sid: sid.to_string(),
697                dir: dir.clone(),
698            });
699        }
700        let writer = EventWriter::spawn_full(
701            &dir,
702            redactor.clone(),
703            project_index.clone(),
704            Some(id.to_string()),
705        )
706        .map_err(SessionOpenError::WriterInit)?;
707        let mut sink = EventSink::new().with_forwarder(writer.sender());
708        if let Some(r) = redactor {
709            sink = sink.with_redactor(r);
710        }
711        let events_path = dir.join("events.jsonl");
712        let messages = replay_messages_from(&events_path)?;
713        let initial_msgs = replay_messages_with_seq(&events_path)?;
714        let all_msgs = replay_all_messages_with_seq(&events_path)?;
715        if let Some(last_seq) = find_last_seq(&events_path)? {
716            sink.restore_seq(last_seq);
717        }
718        let mut initial_context = replay_context_snapshot_from(&events_path);
719        let persisted = PersistedContextState::load(&dir);
720        if !persisted.model.is_empty() {
721            initial_context.model = persisted.model;
722        }
723        initial_context.window_tokens = persisted.window_tokens;
724        initial_context.window_budget = persisted.window_budget;
725        let initial_goal = load_goal(&dir);
726        let (injection_tx, _) = broadcast::channel(32);
727        let (stream_tx, _) = broadcast::channel(2048);
728        let (context_watch, context_rx) = watch::channel(initial_context);
729        let (goal_watch, goal_rx) = watch::channel(initial_goal);
730        let (attach_watch, attach_rx) = watch::channel(0);
731        let (todos_watch, todos_rx) = watch::channel(Vec::new());
732        let (plans_watch, plans_rx) = watch::channel(Vec::new());
733        let events_handle = sink.events_handle();
734        Ok(Self {
735            id,
736            dir,
737            writer: std::sync::Mutex::new(Some(writer)),
738            sink,
739            message_stream: crate::message_stream::MessageStream::with_initial(
740                events_handle,
741                initial_msgs,
742                all_msgs,
743            ),
744            messages: std::sync::Arc::new(std::sync::Mutex::new(messages)),
745            turn: TurnState::new(),
746            watch: WatchHub {
747                stream_tx,
748                context: context_watch,
749                goal: goal_watch,
750                attach: attach_watch,
751                todos: todos_watch,
752                plans: plans_watch,
753                _keepalive: (context_rx, goal_rx, attach_rx, todos_rx, plans_rx),
754            },
755            compaction: {
756                let c = CompactionState::new();
757                if persisted.window_tokens > 0 {
758                    c.last_input_tokens.store(
759                        persisted.window_tokens,
760                        std::sync::atomic::Ordering::Relaxed,
761                    );
762                }
763                c
764            },
765            interactions: InteractionServices::new(),
766            injection_queue: Mutex::new(Vec::new()),
767            injection_tx,
768            last_image_user_msg: Mutex::new(None),
769            read_files: std::sync::Arc::new(
770                std::sync::Mutex::new(std::collections::HashSet::new()),
771            ),
772            fs_access_mode: Mutex::new(None),
773            project_index,
774        })
775    }
776
777    pub fn open_ephemeral() -> Self {
778        let (injection_tx, _) = broadcast::channel(32);
779        let (stream_tx, _) = broadcast::channel(2048);
780        let (context_watch, context_rx) = watch::channel(ContextSnapshot::default());
781        let (goal_watch, goal_rx) = watch::channel(None);
782        let (attach_watch, attach_rx) = watch::channel(0);
783        let (todos_watch, todos_rx) = watch::channel(Vec::new());
784        let (plans_watch, plans_rx) = watch::channel(Vec::new());
785        let sink = EventSink::new();
786        let events_handle = sink.events_handle();
787        Self {
788            id: SessionId::now(),
789            dir: PathBuf::new(),
790            writer: std::sync::Mutex::new(None),
791            sink,
792            message_stream: crate::message_stream::MessageStream::new(events_handle),
793            messages: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
794            turn: TurnState::new(),
795            watch: WatchHub {
796                stream_tx,
797                context: context_watch,
798                goal: goal_watch,
799                attach: attach_watch,
800                todos: todos_watch,
801                plans: plans_watch,
802                _keepalive: (context_rx, goal_rx, attach_rx, todos_rx, plans_rx),
803            },
804            compaction: CompactionState::new(),
805            interactions: InteractionServices::new(),
806            injection_queue: Mutex::new(Vec::new()),
807            injection_tx,
808            last_image_user_msg: Mutex::new(None),
809            read_files: std::sync::Arc::new(
810                std::sync::Mutex::new(std::collections::HashSet::new()),
811            ),
812            fs_access_mode: Mutex::new(None),
813            project_index: None,
814        }
815    }
816
817    pub fn project_index(&self) -> Option<std::sync::Arc<crate::index::AnchorIndex>> {
818        self.project_index.clone()
819    }
820
821    pub fn approval(&self) -> std::sync::Arc<ApprovalRegistry> {
822        self.interactions.approval.clone()
823    }
824
825    pub fn compact_reviews(&self) -> std::sync::Arc<CompactReviewRegistry> {
826        self.interactions.compact_reviews.clone()
827    }
828
829    pub fn forms(&self) -> std::sync::Arc<FormRegistry> {
830        self.interactions.forms.clone()
831    }
832
833    pub fn fs_access_mode(&self) -> Option<crate::fs_access::FsAccessMode> {
834        *self.fs_access_mode.lock().unwrap()
835    }
836
837    pub fn set_fs_access_mode(&self, mode: crate::fs_access::FsAccessMode) {
838        *self.fs_access_mode.lock().unwrap() = Some(mode);
839    }
840
841    pub fn compact_review_mode(&self) -> CompactReviewMode {
842        *self.compaction.review_mode.lock().unwrap()
843    }
844
845    pub fn set_compact_review_mode(&self, mode: CompactReviewMode) {
846        *self.compaction.review_mode.lock().unwrap() = mode;
847    }
848
849    pub fn read_files(
850        &self,
851    ) -> std::sync::Arc<std::sync::Mutex<std::collections::HashSet<std::path::PathBuf>>> {
852        self.read_files.clone()
853    }
854
855    pub fn mark_file_read(&self, path: &std::path::Path) {
856        if let Ok(mut set) = self.read_files.lock() {
857            set.insert(path.to_path_buf());
858            if let Ok(canonical) = std::fs::canonicalize(path) {
859                set.insert(canonical);
860            }
861        }
862    }
863
864    pub fn stream_tx(&self) -> broadcast::Sender<StreamFrame> {
865        self.watch.stream_tx.clone()
866    }
867
868    pub fn stream_subscribe(&self) -> broadcast::Receiver<StreamFrame> {
869        self.watch.stream_tx.subscribe()
870    }
871
872    pub fn id(&self) -> &SessionId {
873        &self.id
874    }
875
876    pub fn dir(&self) -> &Path {
877        &self.dir
878    }
879
880    pub fn transcript_replay(&self) -> Vec<TranscriptEntry> {
881        let Some(path) = self.events_path() else {
882            return Vec::new();
883        };
884        replay_transcript_from(&path).unwrap_or_default()
885    }
886
887    pub fn events_path(&self) -> Option<std::path::PathBuf> {
888        self.writer
889            .lock()
890            .unwrap()
891            .as_ref()
892            .map(|w| w.events_path().to_path_buf())
893    }
894
895    pub async fn plan_system_prompt(&self) -> Option<String> {
896        let store = crate::memory::plan::PlanStore::at(&self.dir);
897        let plan = store.latest().await.ok().flatten()?;
898        Some(crate::tools::plan::render_plan(&plan))
899    }
900
901    pub fn goal(&self) -> Option<String> {
902        if let Some(cached) = self.watch.goal.borrow().clone() {
903            return Some(cached);
904        }
905        load_goal(&self.dir)
906    }
907
908    pub fn subscribe_goal(&self) -> watch::Receiver<Option<String>> {
909        self.watch.goal.subscribe()
910    }
911
912    pub fn goal_watch(&self) -> &watch::Sender<Option<String>> {
913        &self.watch.goal
914    }
915
916    pub fn subscribe_context(&self) -> watch::Receiver<ContextSnapshot> {
917        self.watch.context.subscribe()
918    }
919
920    pub fn subscribe_attach(&self) -> watch::Receiver<usize> {
921        self.watch.attach.subscribe()
922    }
923
924    pub fn subscribe_pending_approvals(&self) -> watch::Receiver<Vec<PendingApproval>> {
925        self.interactions.approval.subscribe()
926    }
927
928    pub fn meta(&self) -> Option<crate::session_meta::SessionMeta> {
929        crate::session_meta::SessionMeta::load(&self.dir)
930    }
931
932    pub fn request_manual_compact(&self) {
933        self.compaction
934            .manual_pending
935            .store(true, std::sync::atomic::Ordering::SeqCst);
936    }
937
938    pub fn take_manual_compact_request(&self) -> bool {
939        self.compaction
940            .manual_pending
941            .swap(false, std::sync::atomic::Ordering::SeqCst)
942    }
943
944    pub fn set_goal(&self, goal: Option<String>) {
945        let _ = self.watch.goal.send(goal);
946    }
947
948    pub fn set_attach_count(&self, count: usize) {
949        let _ = self.watch.attach.send(count);
950    }
951
952    #[allow(clippy::too_many_arguments)]
953    pub fn record_llm_call(
954        &self,
955        model: &str,
956        tokens_in: u64,
957        tokens_out: u64,
958        cache_read: u64,
959        cache_write: u64,
960        ttft_ms: Option<u64>,
961        tokens_per_sec: Option<f64>,
962    ) {
963        self.compaction
964            .last_input_tokens
965            .store(tokens_in, std::sync::atomic::Ordering::Relaxed);
966        self.watch.context.send_modify(|snap| {
967            snap.model = model.to_string();
968            snap.tokens_in = snap.tokens_in.saturating_add(tokens_in);
969            snap.tokens_out = snap.tokens_out.saturating_add(tokens_out);
970            snap.cache_read = snap.cache_read.saturating_add(cache_read);
971            snap.cache_write = snap.cache_write.saturating_add(cache_write);
972            snap.last_ttft_ms = ttft_ms.unwrap_or(0);
973            snap.last_tokens_per_sec = tokens_per_sec.unwrap_or(0.0);
974        });
975        self.refresh_window_snapshot();
976    }
977
978    pub fn last_input_tokens(&self) -> u64 {
979        self.compaction
980            .last_input_tokens
981            .load(std::sync::atomic::Ordering::Relaxed)
982    }
983
984    pub async fn acquire_compact_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
985        self.compaction.lock.lock().await
986    }
987
988    pub async fn acquire_compact_lock_owned(&self) -> tokio::sync::OwnedMutexGuard<()> {
989        self.compaction.lock.clone().lock_owned().await
990    }
991
992    pub fn compact_lock_handle(&self) -> std::sync::Arc<tokio::sync::Mutex<()>> {
993        self.compaction.lock.clone()
994    }
995
996    pub fn refresh_window_snapshot(&self) {
997        let provider_tokens = self.last_input_tokens();
998        let estimated = crate::compaction::estimate_tokens_for_messages(&self.messages());
999        let window = if provider_tokens > 0 {
1000            provider_tokens
1001        } else {
1002            estimated
1003        };
1004        let model = self.last_model();
1005        let budget = crate::model_registry::model_info(&model).context_budget;
1006        self.watch.context.send_modify(|snap| {
1007            snap.window_tokens = window;
1008            if budget > 0 {
1009                snap.window_budget = budget;
1010            }
1011        });
1012        let snap = self.watch.context.borrow();
1013        PersistedContextState {
1014            model,
1015            window_tokens: snap.window_tokens,
1016            window_budget: snap.window_budget,
1017        }
1018        .save(&self.dir);
1019    }
1020
1021    pub fn cumulative_input_tokens(&self) -> u64 {
1022        self.watch.context.borrow().tokens_in
1023    }
1024
1025    pub fn reset_input_tokens_to(&self, tokens: u64) {
1026        self.watch.context.send_modify(|snap| {
1027            snap.tokens_in = tokens;
1028        });
1029    }
1030
1031    pub fn last_model(&self) -> String {
1032        self.watch.context.borrow().model.clone()
1033    }
1034
1035    pub fn update_mcp_server(&self, status: crate::mcp::McpServerStatus) {
1036        self.watch.context.send_modify(|snap| {
1037            if let Some(existing) = snap.mcp_servers.iter_mut().find(|s| s.name == status.name) {
1038                *existing = status;
1039            } else {
1040                snap.mcp_servers.push(status);
1041            }
1042        });
1043    }
1044
1045    pub fn set_memory_recent_count(&self, count: u16) {
1046        self.watch.context.send_modify(|snap| {
1047            snap.memory_recent_count = count;
1048        });
1049    }
1050
1051    pub fn subscribe_todos(&self) -> watch::Receiver<Vec<crate::memory::todo::Todo>> {
1052        self.watch.todos.subscribe()
1053    }
1054
1055    pub fn todos_watch(&self) -> &watch::Sender<Vec<crate::memory::todo::Todo>> {
1056        &self.watch.todos
1057    }
1058
1059    pub fn subscribe_plans(&self) -> watch::Receiver<Vec<crate::memory::plan::Plan>> {
1060        self.watch.plans.subscribe()
1061    }
1062
1063    pub fn plans_watch(&self) -> &watch::Sender<Vec<crate::memory::plan::Plan>> {
1064        &self.watch.plans
1065    }
1066
1067    pub async fn refresh_plans_from_store_async(&self) {
1068        if self.dir.as_os_str().is_empty() {
1069            return;
1070        }
1071        let store = crate::memory::plan::PlanStore::at(&self.dir);
1072        match store.list().await {
1073            Ok(list) => {
1074                let _ = self.watch.plans.send(list);
1075            }
1076            Err(e) => {
1077                crate::notify!(
1078                    warn,
1079                    location = Log,
1080                    stack = dedupe("memory.refresh_plans_async", 60_000),
1081                    "refresh_plans_from_store_async: {e}"
1082                );
1083            }
1084        }
1085    }
1086
1087    pub fn refresh_todos_from_store(&self) {
1088        if self.dir.as_os_str().is_empty() {
1089            return;
1090        }
1091        let store = crate::memory::todo::TodoStore::at(&self.dir);
1092        match tokio::task::block_in_place(|| {
1093            tokio::runtime::Handle::try_current()
1094                .ok()
1095                .map(|h| h.block_on(store.list()))
1096        }) {
1097            Some(Ok(list)) => {
1098                let _ = self.watch.todos.send(list);
1099            }
1100            Some(Err(e)) => {
1101                crate::notify!(
1102                    warn,
1103                    location = Log,
1104                    stack = dedupe("memory.refresh_todos", 60_000),
1105                    "refresh_todos_from_store: {e}"
1106                );
1107            }
1108            None => {}
1109        }
1110    }
1111
1112    pub async fn refresh_todos_from_store_async(&self) {
1113        if self.dir.as_os_str().is_empty() {
1114            return;
1115        }
1116        let store = crate::memory::todo::TodoStore::at(&self.dir);
1117        match store.list().await {
1118            Ok(list) => {
1119                let _ = self.watch.todos.send(list);
1120            }
1121            Err(e) => {
1122                crate::notify!(
1123                    warn,
1124                    location = Log,
1125                    stack = dedupe("memory.refresh_todos_async", 60_000),
1126                    "refresh_todos_from_store_async: {e}"
1127                );
1128            }
1129        }
1130    }
1131
1132    pub fn sink(&self) -> &EventSink {
1133        &self.sink
1134    }
1135
1136    /// Single-writer append. Emits the matching event before the in-memory push
1137    /// so events.jsonl remains the authority (§I5).
1138    pub fn append_message(&self, msg: Message, flow_run_id: Option<FlowRunId>) {
1139        AppendMessageCommand { msg, flow_run_id }.execute(self);
1140    }
1141
1142    pub fn emit_attachment_degrade(
1143        &self,
1144        message_seq: u64,
1145        part_index: usize,
1146        file_basename: String,
1147        reason: String,
1148    ) {
1149        self.sink.emit(Event::AttachmentDegraded {
1150            turn_id: None,
1151            flow_run_id: None,
1152            message_seq,
1153            part_index,
1154            file_basename,
1155            reason,
1156        });
1157    }
1158
1159    pub fn record_attachment_degrade(&self, reason: &str) -> usize {
1160        let target = self.last_image_user_msg.lock().unwrap().take();
1161        let Some(entry) = target else {
1162            return 0;
1163        };
1164        let turn_id = self.turn.current_turn.lock().unwrap().clone();
1165        for (part_index, basename) in &entry.images {
1166            self.sink.emit(Event::AttachmentDegraded {
1167                turn_id: turn_id.clone(),
1168                flow_run_id: None,
1169                message_seq: entry.message_seq,
1170                part_index: *part_index,
1171                file_basename: basename.clone(),
1172                reason: reason.into(),
1173            });
1174        }
1175        if let Ok(mut msgs) = self.messages.lock() {
1176            for m in msgs.iter_mut() {
1177                for (part_index, basename) in &entry.images {
1178                    if let Some(part) = m.parts.get_mut(*part_index)
1179                        && matches!(part, crate::message::MessagePart::Image { .. })
1180                    {
1181                        *part = crate::message::MessagePart::Text {
1182                            text: format!("[attachment unavailable: {basename} — {reason}]"),
1183                        };
1184                    }
1185                }
1186            }
1187        }
1188        entry.images.len()
1189    }
1190
1191    pub fn messages(&self) -> crate::message_stream::MessageWindow {
1192        self.message_stream.window()
1193    }
1194
1195    pub fn messages_full(&self) -> std::sync::Arc<Vec<Message>> {
1196        self.message_stream.full_messages()
1197    }
1198
1199    pub fn messages_handle(&self) -> std::sync::Arc<std::sync::Mutex<Vec<Message>>> {
1200        self.messages.clone()
1201    }
1202
1203    pub fn message_count(&self) -> usize {
1204        self.messages().len()
1205    }
1206
1207    pub fn user_message_count(&self) -> usize {
1208        self.messages()
1209            .iter()
1210            .filter(|m| matches!(m.role, MessageRole::User))
1211            .count()
1212    }
1213
1214    pub fn push_system_note(&self, text: String) {
1215        let _ = self
1216            .watch
1217            .stream_tx
1218            .send(crate::stream::StreamFrame::Note(text));
1219    }
1220
1221    pub fn approval_cooldown_ok_for_compact(&self) -> bool {
1222        self.sink.last_compact_ago_seconds().is_none_or(|s| s >= 60)
1223    }
1224
1225    pub fn emit_compact_warning(
1226        &self,
1227        model: &str,
1228        current_tokens: u64,
1229        threshold: u64,
1230        budget: u64,
1231        reason: &str,
1232    ) {
1233        let message = format!(
1234            "context {current_tokens} > threshold {threshold} (budget {budget}, model {model}); skipping compaction: {reason}"
1235        );
1236        self.sink.emit(Event::WatchWarn {
1237            turn_id: self.turn.current_turn.lock().unwrap().clone(),
1238            flow_run_id: None,
1239            target: "context.compaction".into(),
1240            trigger: "auto_compact".into(),
1241            message,
1242        });
1243        self.push_system_note(format!("[warn] compaction skipped: {reason}"));
1244    }
1245
1246    /// Convenience wrapper that computes the compact range and token count
1247    /// from the current message window. Used by tests and internal callers
1248    /// that don't already have a pre-computed range.
1249    pub fn compact_messages_auto(&self, summary: String) -> Option<CompactResult> {
1250        let msgs = self.messages();
1251        let tokens = crate::compaction::estimate_tokens_for_messages(&msgs);
1252        let info = crate::model_registry::model_info(&self.last_model());
1253        let target = info.compaction_target_after();
1254        let range = crate::compaction::find_compact_range(&msgs, target)?;
1255        self.compact_messages(summary, range, tokens)
1256    }
1257
1258    pub fn compact_messages(
1259        &self,
1260        summary: String,
1261        range: crate::compaction::CompactRange,
1262        before_tokens: u64,
1263    ) -> Option<CompactResult> {
1264        use crate::compaction::{estimate_tokens_for_messages, replace_range_with_summary};
1265        let msgs = self.messages();
1266        let turn_id = msgs
1267            .get(range.start)
1268            .map(|m| m.turn_id.clone())
1269            .unwrap_or_else(TurnId::now);
1270        let after = replace_range_with_summary(&msgs, &range, summary.clone(), turn_id.clone());
1271        let after_tokens = estimate_tokens_for_messages(&after);
1272        if after_tokens >= before_tokens {
1273            self.push_system_note(format!(
1274                "compaction skipped: summary would not shrink transcript ({} >= {} tokens)",
1275                after_tokens, before_tokens
1276            ));
1277            return None;
1278        }
1279        let replacement_msg = after.first().cloned().unwrap_or_else(|| {
1280            Message::system_compact_summary(
1281                turn_id.clone(),
1282                summary.clone(),
1283                range.start as u64,
1284                range.end.saturating_sub(1) as u64,
1285                range.end - range.start,
1286            )
1287        });
1288        self.sink.mark_compacted();
1289        let replacement_seq = self.sink.next_seq_peek();
1290        self.sink.emit(Event::SystemMsg {
1291            turn_id: turn_id.clone(),
1292            message: replacement_msg,
1293        });
1294        self.sink.emit(Event::ContextCompact {
1295            session_id: self.id.to_string(),
1296            before_tokens,
1297            after_tokens,
1298            compacted_range_start: range.start as u64,
1299            compacted_range_end: range.end.saturating_sub(1) as u64,
1300            summary_text: Some(summary.clone()),
1301            replacement_msg_seq: Some(replacement_seq),
1302        });
1303        self.sink.emit(Event::CompactionSummary {
1304            session_id: self.id.to_string(),
1305            range_start: range.start as u64,
1306            range_end: range.end.saturating_sub(1) as u64,
1307            compacted_count: range.end - range.start,
1308            before_tokens,
1309            after_tokens,
1310            summary: summary.clone(),
1311        });
1312        let _ = self
1313            .watch
1314            .stream_tx
1315            .send(crate::stream::StreamFrame::CompactionSummary {
1316                phase: crate::stream::CompactionPhase::Finished,
1317                range_start: range.start,
1318                range_end: range.end.saturating_sub(1),
1319                summary,
1320                before_tokens,
1321                after_tokens,
1322                compacted_count: range.end - range.start,
1323            });
1324        let checkpoint_messages = self.messages();
1325        let window_tokens = estimate_tokens_for_messages(&checkpoint_messages);
1326        self.compaction
1327            .last_input_tokens
1328            .store(window_tokens, std::sync::atomic::Ordering::Relaxed);
1329        self.refresh_window_snapshot();
1330        self.sink.emit(Event::Checkpoint {
1331            session_id: self.id.to_string(),
1332            messages: checkpoint_messages.to_vec(),
1333            window_tokens,
1334        });
1335        Some(CompactResult {
1336            before_tokens,
1337            after_tokens,
1338            compacted_start: range.start,
1339            compacted_end: range.end,
1340        })
1341    }
1342
1343    pub fn begin_turn(&self, user_msg: Message) -> TurnId {
1344        BeginTurnCommand { user_msg }.execute(self)
1345    }
1346
1347    pub fn mark_streamed(&self) {
1348        self.turn
1349            .streamed
1350            .store(true, std::sync::atomic::Ordering::Relaxed);
1351    }
1352
1353    pub fn take_streamed_flag(&self) -> bool {
1354        self.turn
1355            .streamed
1356            .swap(false, std::sync::atomic::Ordering::Relaxed)
1357    }
1358
1359    pub fn end_turn(&self) {
1360        self.turn
1361            .streamed
1362            .store(false, std::sync::atomic::Ordering::Relaxed);
1363        let turn_id = self.turn.current_turn.lock().unwrap().take();
1364        if let Some(turn_id) = turn_id {
1365            let mut q = self.injection_queue.lock().unwrap();
1366            for inj in q.iter_mut() {
1367                if inj.state == InjectionState::Pending && inj.turn_id == turn_id {
1368                    inj.state = InjectionState::Cancelled;
1369                    let _ = self.injection_tx.send(inj.clone());
1370                }
1371            }
1372            drop(q);
1373            self.sink.emit(Event::TurnEnd { turn_id });
1374        }
1375    }
1376
1377    pub fn current_turn(&self) -> Option<TurnId> {
1378        self.turn.current_turn.lock().unwrap().clone()
1379    }
1380
1381    pub fn enqueue_injection(&self, text: impl Into<String>) -> Result<InjectionId, EnqueueError> {
1382        self.enqueue_injection_with_level(text, crate::injection::InjectionLevel::L1Nudge, None)
1383    }
1384
1385    pub fn enqueue_injection_with_level(
1386        &self,
1387        text: impl Into<String>,
1388        level: crate::injection::InjectionLevel,
1389        redirect_target: Option<String>,
1390    ) -> Result<InjectionId, EnqueueError> {
1391        let turn_id = self
1392            .turn
1393            .current_turn
1394            .lock()
1395            .unwrap()
1396            .clone()
1397            .ok_or(EnqueueError::NoActiveTurn)?;
1398        let inj = Injection::with_level(turn_id.clone(), text, level, redirect_target);
1399        let id = inj.id.clone();
1400        self.sink.emit(Event::UserInject {
1401            turn_id,
1402            injection: inj.clone(),
1403        });
1404        self.injection_queue.lock().unwrap().push(inj.clone());
1405        let _ = self.injection_tx.send(inj);
1406        Ok(id)
1407    }
1408
1409    pub fn subscribe_injections(&self) -> broadcast::Receiver<Injection> {
1410        self.injection_tx.subscribe()
1411    }
1412
1413    pub fn mark_injection_consumed(&self, id: &InjectionId) {
1414        let mut q = self.injection_queue.lock().unwrap();
1415        for inj in q.iter_mut() {
1416            if inj.id == *id && inj.state == InjectionState::Pending {
1417                inj.state = InjectionState::Injected;
1418                let _ = self.injection_tx.send(inj.clone());
1419                return;
1420            }
1421        }
1422    }
1423
1424    pub fn peek_pending_l2_or_higher(&self, turn_id: &TurnId) -> Option<Injection> {
1425        let q = self.injection_queue.lock().unwrap();
1426        q.iter()
1427            .find(|i| {
1428                i.state == InjectionState::Pending
1429                    && i.turn_id == *turn_id
1430                    && !matches!(i.level, crate::injection::InjectionLevel::L1Nudge)
1431            })
1432            .cloned()
1433    }
1434
1435    /// Drain all Pending injections for `turn_id`. Marks them Injected.
1436    /// Returns them in creation order.
1437    pub fn drain_injections(&self, turn_id: &TurnId) -> Vec<Injection> {
1438        let mut q = self.injection_queue.lock().unwrap();
1439        let mut out = Vec::new();
1440        for inj in q.iter_mut() {
1441            if inj.state == InjectionState::Pending && inj.turn_id == *turn_id {
1442                inj.state = InjectionState::Injected;
1443                let _ = self.injection_tx.send(inj.clone());
1444                out.push(inj.clone());
1445            }
1446        }
1447        out
1448    }
1449
1450    pub fn list_pending_injections(&self) -> Vec<Injection> {
1451        self.injection_queue
1452            .lock()
1453            .unwrap()
1454            .iter()
1455            .filter(|i| i.state == InjectionState::Pending)
1456            .cloned()
1457            .collect()
1458    }
1459
1460    pub fn cancel_flow(&self) {
1461        self.turn.flow_cancel.lock().unwrap().cancel();
1462    }
1463
1464    pub fn flow_cancel_token(&self) -> CancellationToken {
1465        self.turn.flow_cancel.lock().unwrap().clone()
1466    }
1467
1468    pub async fn shutdown(&self) {
1469        let writer = self.writer.lock().unwrap().take();
1470        if let Some(writer) = writer {
1471            writer.shutdown().await;
1472        }
1473    }
1474
1475    // Rides FIFO queue ordering: once flush's own barrier is written,
1476    // every earlier sink.emit is on disk too.
1477    #[allow(clippy::await_holding_lock)]
1478    pub async fn flush_writer(&self) {
1479        let guard = self.writer.lock().unwrap();
1480        let Some(ref writer) = *guard else {
1481            return;
1482        };
1483        writer.flush().await;
1484    }
1485}
1486
1487#[derive(Debug, thiserror::Error)]
1488pub enum EnqueueError {
1489    #[error("enqueue_injection called with no active turn")]
1490    NoActiveTurn,
1491}
1492
1493pub struct AppendMessageCommand {
1494    pub msg: Message,
1495    pub flow_run_id: Option<FlowRunId>,
1496}
1497
1498impl AppendMessageCommand {
1499    pub fn execute(&self, session: &Session) -> u64 {
1500        let flow_run_id_str = self.flow_run_id.as_ref().map(|r| r.0.to_string());
1501        let msg =
1502            crate::tools::tool_output::maybe_truncate_tool_message(&self.msg, Some(&session.dir));
1503        let event = match msg.role {
1504            MessageRole::User => Event::UserMsg {
1505                turn_id: msg.turn_id.clone(),
1506                message: msg.clone(),
1507            },
1508            MessageRole::Assistant => {
1509                let _ = session
1510                    .watch
1511                    .stream_tx
1512                    .send(crate::stream::StreamFrame::AssistantMsg {
1513                        flow_run_id: flow_run_id_str.clone(),
1514                        message: msg.clone(),
1515                    });
1516                for source in extract_mermaid_blocks(&msg) {
1517                    let _ =
1518                        session
1519                            .watch
1520                            .stream_tx
1521                            .send(crate::stream::StreamFrame::MermaidDiagram {
1522                                source: source.clone(),
1523                            });
1524                    session
1525                        .sink
1526                        .emit(crate::event::Event::MermaidDiagram { source });
1527                }
1528                Event::AssistantMsg {
1529                    turn_id: msg.turn_id.clone(),
1530                    flow_run_id: self.flow_run_id.clone(),
1531                    message: msg.clone(),
1532                }
1533            }
1534            MessageRole::Tool => {
1535                let _ = session
1536                    .watch
1537                    .stream_tx
1538                    .send(crate::stream::StreamFrame::ToolResultMsg {
1539                        flow_run_id: flow_run_id_str.clone(),
1540                        message: msg.clone(),
1541                    });
1542                Event::ToolResultMsg {
1543                    turn_id: msg.turn_id.clone(),
1544                    flow_run_id: self.flow_run_id.clone(),
1545                    message: msg.clone(),
1546                }
1547            }
1548            MessageRole::System => Event::SystemMsg {
1549                turn_id: msg.turn_id.clone(),
1550                message: msg.clone(),
1551            },
1552        };
1553        let seq = session.sink.emit_returning_seq(event);
1554        if matches!(msg.role, MessageRole::User) {
1555            let images: Vec<(usize, String)> = msg
1556                .parts
1557                .iter()
1558                .enumerate()
1559                .filter_map(|(i, p)| match p {
1560                    crate::message::MessagePart::Image { source } => {
1561                        let basename = match &source.data {
1562                            crate::message::ImageData::Path { path } => path
1563                                .file_name()
1564                                .and_then(|n| n.to_str())
1565                                .unwrap_or("unknown")
1566                                .to_string(),
1567                            crate::message::ImageData::Base64 { .. } => "base64".into(),
1568                        };
1569                        Some((i, basename))
1570                    }
1571                    _ => None,
1572                })
1573                .collect();
1574            if !images.is_empty() {
1575                *session.last_image_user_msg.lock().unwrap() = Some(LastImageUserMsg {
1576                    message_seq: seq,
1577                    images,
1578                });
1579            }
1580        }
1581        session.messages.lock().unwrap().push(msg.clone());
1582        seq
1583    }
1584}
1585
1586pub struct BeginTurnCommand {
1587    pub user_msg: Message,
1588}
1589
1590impl BeginTurnCommand {
1591    pub fn execute(&self, session: &Session) -> TurnId {
1592        let turn_id = self.user_msg.turn_id.clone();
1593        *session.turn.current_turn.lock().unwrap() = Some(turn_id.clone());
1594        *session.turn.flow_cancel.lock().unwrap() = tokio_util::sync::CancellationToken::new();
1595        session.sink.emit(Event::TurnStart {
1596            turn_id: turn_id.clone(),
1597        });
1598        AppendMessageCommand {
1599            msg: self.user_msg.clone(),
1600            flow_run_id: None,
1601        }
1602        .execute(session);
1603        turn_id
1604    }
1605}
1606
1607fn extract_mermaid_blocks(msg: &crate::message::Message) -> Vec<String> {
1608    let text = msg.text_concat();
1609    let mut blocks = Vec::new();
1610    let mut lines = text.lines().peekable();
1611    while let Some(line) = lines.next() {
1612        let trimmed = line.trim();
1613        if trimmed.starts_with("```") {
1614            let lang = trimmed.trim_start_matches("```").trim();
1615            if lang == "mermaid" {
1616                let mut source = String::new();
1617                for inner in lines.by_ref() {
1618                    if inner.trim() == "```" {
1619                        break;
1620                    }
1621                    if !source.is_empty() {
1622                        source.push('\n');
1623                    }
1624                    source.push_str(inner);
1625                }
1626                if !source.is_empty() {
1627                    blocks.push(source);
1628                }
1629            } else {
1630                for inner in lines.by_ref() {
1631                    if inner.trim() == "```" {
1632                        break;
1633                    }
1634                }
1635            }
1636        }
1637    }
1638    blocks
1639}
1640
1641#[cfg(test)]
1642mod tests {
1643    use super::*;
1644    use tempfile::TempDir;
1645
1646    fn write_events(dir: &Path, lines: &[&str]) {
1647        let path = dir.join("events.jsonl");
1648        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
1649    }
1650
1651    #[test]
1652    fn replay_applies_attachment_degraded_patch() {
1653        let dir = TempDir::new().unwrap();
1654        let user_msg = r#"{"type":"user_msg","seq":5,"turn_id":"019f0000-0000-7000-0000-000000000001","message":{"role":"user","parts":[{"type":"image","source":{"media_type":"image/png","data":{"kind":"path","path":"/tmp/photo.png"}}},{"type":"text","text":"describe"}],"turn_id":"019f0000-0000-7000-0000-000000000001"},"ts":"2026-07-07T00:00:00Z"}"#;
1655        let degrade = r#"{"type":"attachment_degraded","seq":6,"turn_id":null,"flow_run_id":null,"message_seq":5,"part_index":0,"file_basename":"photo.png","reason":"image_too_large","ts":"2026-07-07T00:00:01Z"}"#;
1656        write_events(dir.path(), &[user_msg, degrade]);
1657        let entries = replay_transcript_from(&dir.path().join("events.jsonl")).unwrap();
1658        let msg = entries
1659            .into_iter()
1660            .find_map(|e| match e {
1661                TranscriptEntry::Message { message, .. } => Some(message),
1662                _ => None,
1663            })
1664            .unwrap();
1665        assert_eq!(msg.parts.len(), 2);
1666        match &msg.parts[0] {
1667            crate::message::MessagePart::Text { text } => {
1668                assert!(text.contains("photo.png"), "expected basename: {text}");
1669                assert!(text.contains("image_too_large"), "expected reason: {text}");
1670                assert!(text.starts_with("[attachment unavailable"));
1671            }
1672            other => panic!("expected Text stub, got {other:?}"),
1673        }
1674        assert!(matches!(
1675            msg.parts[1],
1676            crate::message::MessagePart::Text { .. }
1677        ));
1678    }
1679
1680    #[test]
1681    fn approval_registry_auto_approves_when_level_leq_ceiling() {
1682        let reg = ApprovalRegistry::new();
1683        reg.set_auto_ceiling(crate::tool::ApprovalLevel::Approve);
1684        let pending = PendingApproval {
1685            tool_use_id: "tu1".into(),
1686            tool_name: "fs.read".into(),
1687            args_preview: "{}".into(),
1688            preview: None,
1689            level: crate::tool::ApprovalLevel::Auto,
1690            run_id: FlowRunId::now(),
1691            emitted_at: chrono::Utc::now(),
1692            bypass_auto_ceiling: false,
1693        };
1694        let rx = reg.request(pending);
1695        let got = rx.blocking_recv().unwrap();
1696        assert!(matches!(got, ApprovalDecision::Approve));
1697        assert!(reg.list_pending().is_empty());
1698    }
1699
1700    #[test]
1701    fn approval_registry_queues_when_level_above_ceiling() {
1702        let reg = std::sync::Arc::new(ApprovalRegistry::new());
1703        reg.set_auto_ceiling(crate::tool::ApprovalLevel::Auto);
1704        let pending = PendingApproval {
1705            tool_use_id: "tu42".into(),
1706            tool_name: "fs.write".into(),
1707            args_preview: "{}".into(),
1708            preview: None,
1709            level: crate::tool::ApprovalLevel::Approve,
1710            run_id: FlowRunId::now(),
1711            emitted_at: chrono::Utc::now(),
1712            bypass_auto_ceiling: false,
1713        };
1714        let mut rx = reg.request(pending);
1715        assert_eq!(reg.list_pending().len(), 1);
1716        assert!(rx.try_recv().is_err(), "should still be queued");
1717        assert!(reg.decide("tu42", ApprovalDecision::Approve));
1718        let got = rx.blocking_recv().unwrap();
1719        assert!(matches!(got, ApprovalDecision::Approve));
1720        assert!(reg.list_pending().is_empty());
1721    }
1722
1723    #[test]
1724    fn approval_registry_decide_all_flushes_queue() {
1725        let reg = ApprovalRegistry::new();
1726        reg.set_auto_ceiling(crate::tool::ApprovalLevel::Auto);
1727        let mut rxs = Vec::new();
1728        for i in 0..3 {
1729            rxs.push(reg.request(PendingApproval {
1730                tool_use_id: format!("tu{i}"),
1731                tool_name: "bash.exec".into(),
1732                args_preview: "{}".into(),
1733                preview: None,
1734                level: crate::tool::ApprovalLevel::Dangerous,
1735                run_id: FlowRunId::now(),
1736                emitted_at: chrono::Utc::now(),
1737                bypass_auto_ceiling: false,
1738            }));
1739        }
1740        assert_eq!(reg.list_pending().len(), 3);
1741        assert_eq!(
1742            reg.decide_all(ApprovalDecision::Deny {
1743                reason: "user cancelled".into()
1744            }),
1745            3
1746        );
1747        assert!(reg.list_pending().is_empty());
1748    }
1749
1750    #[test]
1751    fn compact_review_registry_auto_accepts_when_no_subscriber() {
1752        let reg = CompactReviewRegistry::new();
1753        let pending = PendingCompactReview {
1754            review_id: "r1".into(),
1755            summary: "gist".into(),
1756            slice_preview: String::new(),
1757            slice_count: 0,
1758            range_start: 0,
1759            range_end: 0,
1760            tokens_before: 0,
1761            emitted_at: chrono::Utc::now(),
1762        };
1763        let rx = reg.request(pending);
1764        let got = rx.blocking_recv().unwrap();
1765        assert!(matches!(got, CompactReviewDecision::AcceptAsIs));
1766        assert!(reg.list_pending().is_none());
1767    }
1768
1769    #[test]
1770    fn compact_review_registry_holds_pending_and_decides() {
1771        let reg = std::sync::Arc::new(CompactReviewRegistry::new());
1772        let _sub = reg.subscribe();
1773        let pending = PendingCompactReview {
1774            review_id: "r2".into(),
1775            summary: "old".into(),
1776            slice_preview: "slice".into(),
1777            slice_count: 3,
1778            range_start: 1,
1779            range_end: 4,
1780            tokens_before: 500,
1781            emitted_at: chrono::Utc::now(),
1782        };
1783        let mut rx = reg.request(pending);
1784        assert!(rx.try_recv().is_err(), "should be queued");
1785        assert!(reg.list_pending().is_some());
1786        assert!(reg.decide(
1787            "r2",
1788            CompactReviewDecision::AcceptEdited {
1789                summary: "new".into()
1790            }
1791        ));
1792        let got = rx.blocking_recv().unwrap();
1793        match got {
1794            CompactReviewDecision::AcceptEdited { summary } => assert_eq!(summary, "new"),
1795            other => panic!("unexpected decision: {other:?}"),
1796        }
1797        assert!(reg.list_pending().is_none());
1798    }
1799
1800    #[test]
1801    fn compact_review_registry_reject_flushes() {
1802        let reg = std::sync::Arc::new(CompactReviewRegistry::new());
1803        let _sub = reg.subscribe();
1804        let rx = reg.request(PendingCompactReview {
1805            review_id: "r3".into(),
1806            summary: String::new(),
1807            slice_preview: String::new(),
1808            slice_count: 0,
1809            range_start: 0,
1810            range_end: 0,
1811            tokens_before: 0,
1812            emitted_at: chrono::Utc::now(),
1813        });
1814        assert!(reg.decide("r3", CompactReviewDecision::Reject));
1815        let got = rx.blocking_recv().unwrap();
1816        assert!(matches!(got, CompactReviewDecision::Reject));
1817    }
1818
1819    #[test]
1820    fn replay_context_snapshot_accumulates_llm_call_usage() {
1821        let dir = TempDir::new().unwrap();
1822        let events = [
1823            r#"{"type":"llm_call","seq":1,"model":"anthropic/claude-4","provider":"anthropic","usage":{"input":100,"cached_input":10,"output":50,"cache_write":0},"wallclock_ms":1000,"status":{"kind":"ok"},"run_id":"019f0000-0000-7000-0000-000000000099","ts":"2026-07-08T00:00:00Z"}"#,
1824            r#"{"type":"user_msg","seq":2,"turn_id":"019f0000-0000-7000-0000-000000000002","message":{"role":"user","parts":[{"type":"text","text":"hi"}],"turn_id":"019f0000-0000-7000-0000-000000000002"},"ts":"2026-07-08T00:00:00Z"}"#,
1825            r#"{"type":"llm_call","seq":3,"model":"anthropic/claude-4","provider":"anthropic","usage":{"input":200,"cached_input":0,"output":80,"cache_write":0},"wallclock_ms":1000,"status":{"kind":"ok"},"run_id":"019f0000-0000-7000-0000-000000000099","ts":"2026-07-08T00:00:01Z"}"#,
1826        ];
1827        write_events(dir.path(), &events);
1828        let snap = replay_context_snapshot_from(&dir.path().join("events.jsonl"));
1829        assert_eq!(snap.model, "anthropic/claude-4");
1830        assert_eq!(snap.tokens_in, 310);
1831        assert_eq!(snap.tokens_out, 130);
1832    }
1833
1834    #[test]
1835    fn replay_context_snapshot_skips_subagent_llm_calls() {
1836        let dir = TempDir::new().unwrap();
1837        let events = [
1838            r#"{"type":"llm_call","seq":1,"model":"zhipuai/glm-5.2","provider":"zhipu","usage":{"input":100,"cached_input":0,"output":50,"cache_write":0},"wallclock_ms":1000,"status":{"kind":"ok"},"run_id":"019f0000-0000-7000-0000-000000000099","ts":"2026-07-08T00:00:00Z"}"#,
1839            r#"{"type":"llm_call","seq":2,"model":"gpt-4o-mini","provider":"openai","usage":{"input":200,"cached_input":0,"output":80,"cache_write":0},"wallclock_ms":1000,"status":{"kind":"ok"},"run_id":null,"ts":"2026-07-08T00:00:01Z"}"#,
1840        ];
1841        write_events(dir.path(), &events);
1842        let snap = replay_context_snapshot_from(&dir.path().join("events.jsonl"));
1843        assert_eq!(snap.model, "zhipuai/glm-5.2");
1844        assert_eq!(snap.tokens_in, 100);
1845        assert_eq!(snap.tokens_out, 50);
1846    }
1847
1848    #[test]
1849    fn compact_review_mode_parses_all_variants() {
1850        assert_eq!(
1851            CompactReviewMode::parse("always"),
1852            Some(CompactReviewMode::Always)
1853        );
1854        assert_eq!(
1855            CompactReviewMode::parse("manual-only"),
1856            Some(CompactReviewMode::ManualOnly)
1857        );
1858        assert_eq!(
1859            CompactReviewMode::parse("manual_only"),
1860            Some(CompactReviewMode::ManualOnly)
1861        );
1862        assert_eq!(
1863            CompactReviewMode::parse("never"),
1864            Some(CompactReviewMode::Never)
1865        );
1866        assert_eq!(CompactReviewMode::parse(" bogus "), None);
1867    }
1868
1869    #[test]
1870    fn compact_review_mode_should_review_matrix() {
1871        assert!(CompactReviewMode::Always.should_review(false));
1872        assert!(CompactReviewMode::Always.should_review(true));
1873        assert!(!CompactReviewMode::ManualOnly.should_review(false));
1874        assert!(CompactReviewMode::ManualOnly.should_review(true));
1875        assert!(!CompactReviewMode::Never.should_review(false));
1876        assert!(!CompactReviewMode::Never.should_review(true));
1877    }
1878
1879    #[test]
1880    fn compact_review_registry_new_request_rejects_previous() {
1881        let reg = std::sync::Arc::new(CompactReviewRegistry::new());
1882        let _sub = reg.subscribe();
1883        let rx_a = reg.request(PendingCompactReview {
1884            review_id: "rA".into(),
1885            summary: String::new(),
1886            slice_preview: String::new(),
1887            slice_count: 0,
1888            range_start: 0,
1889            range_end: 0,
1890            tokens_before: 0,
1891            emitted_at: chrono::Utc::now(),
1892        });
1893        let _rx_b = reg.request(PendingCompactReview {
1894            review_id: "rB".into(),
1895            summary: String::new(),
1896            slice_preview: String::new(),
1897            slice_count: 0,
1898            range_start: 0,
1899            range_end: 0,
1900            tokens_before: 0,
1901            emitted_at: chrono::Utc::now(),
1902        });
1903        let got = rx_a.blocking_recv().unwrap();
1904        assert!(matches!(got, CompactReviewDecision::Reject));
1905    }
1906
1907    fn mk_form(form_id: &str, prompt: &str) -> crate::form::PendingForm {
1908        crate::form::PendingForm {
1909            form_id: form_id.into(),
1910            run_id: crate::event::FlowRunId::now(),
1911            tool_use_id: "tu".into(),
1912            kind: crate::form::FormKind::Confirm {
1913                prompt: prompt.into(),
1914            },
1915            emitted_at: chrono::Utc::now(),
1916        }
1917    }
1918
1919    #[test]
1920    fn form_registry_auto_cancels_without_subscriber() {
1921        let reg = FormRegistry::new();
1922        let rx = reg.request(mk_form("f1", "sure?"));
1923        let got = rx.blocking_recv().unwrap();
1924        assert_eq!(got, crate::form::FormAnswer::Cancelled);
1925        assert!(reg.list_pending().is_empty());
1926    }
1927
1928    #[test]
1929    fn form_registry_delivers_answer_by_form_id() {
1930        let reg = std::sync::Arc::new(FormRegistry::new());
1931        let _sub = reg.subscribe();
1932        let rx = reg.request(mk_form("fA", "?"));
1933        assert_eq!(reg.list_pending().len(), 1);
1934        let ok = reg.submit("fA", crate::form::FormAnswer::Confirmed { value: true });
1935        assert!(ok);
1936        let got = rx.blocking_recv().unwrap();
1937        assert_eq!(got, crate::form::FormAnswer::Confirmed { value: true });
1938        assert!(reg.list_pending().is_empty());
1939    }
1940
1941    #[test]
1942    fn form_registry_submit_unknown_id_is_noop() {
1943        let reg = std::sync::Arc::new(FormRegistry::new());
1944        let _sub = reg.subscribe();
1945        let _rx = reg.request(mk_form("real", "?"));
1946        assert!(!reg.submit("ghost", crate::form::FormAnswer::Cancelled));
1947        assert_eq!(reg.list_pending().len(), 1);
1948    }
1949
1950    #[test]
1951    fn form_registry_cancel_all_flushes_pending() {
1952        let reg = std::sync::Arc::new(FormRegistry::new());
1953        let _sub = reg.subscribe();
1954        let rx_a = reg.request(mk_form("a", "?"));
1955        let rx_b = reg.request(mk_form("b", "?"));
1956        reg.cancel_all();
1957        assert_eq!(
1958            rx_a.blocking_recv().unwrap(),
1959            crate::form::FormAnswer::Cancelled
1960        );
1961        assert_eq!(
1962            rx_b.blocking_recv().unwrap(),
1963            crate::form::FormAnswer::Cancelled
1964        );
1965        assert!(reg.list_pending().is_empty());
1966    }
1967
1968    #[test]
1969    fn form_registry_queues_multiple_pending() {
1970        let reg = std::sync::Arc::new(FormRegistry::new());
1971        let _sub = reg.subscribe();
1972        let _rx1 = reg.request(mk_form("1", "?"));
1973        let _rx2 = reg.request(mk_form("2", "?"));
1974        let pending = reg.list_pending();
1975        assert_eq!(pending.len(), 2);
1976        assert_eq!(pending[0].form_id, "1");
1977        assert_eq!(pending[1].form_id, "2");
1978    }
1979
1980    #[test]
1981    fn replay_without_degraded_events_preserves_image_parts() {
1982        let dir = TempDir::new().unwrap();
1983        let user_msg = r#"{"type":"user_msg","seq":1,"turn_id":"019f0000-0000-7000-0000-000000000002","message":{"role":"user","parts":[{"type":"image","source":{"media_type":"image/png","data":{"kind":"path","path":"/tmp/x.png"}}}],"turn_id":"019f0000-0000-7000-0000-000000000002"},"ts":"2026-07-07T00:00:00Z"}"#;
1984        write_events(dir.path(), &[user_msg]);
1985        let entries = replay_transcript_from(&dir.path().join("events.jsonl")).unwrap();
1986        let msg = entries
1987            .into_iter()
1988            .find_map(|e| match e {
1989                TranscriptEntry::Message { message, .. } => Some(message),
1990                _ => None,
1991            })
1992            .unwrap();
1993        assert!(matches!(
1994            msg.parts[0],
1995            crate::message::MessagePart::Image { .. }
1996        ));
1997    }
1998
1999    #[test]
2000    fn replay_messages_from_old_format_no_seq_no_ts() {
2001        let dir = TempDir::new().unwrap();
2002        // Old-style JSONL: no seq, no ts on events
2003        let user_json = r#"{"type":"user_msg","turn_id":"019f0000-0000-7000-0000-000000000001","message":{"role":"user","parts":[{"type":"text","text":"hello"}],"turn_id":"019f0000-0000-7000-0000-000000000001"}}"#;
2004        let asst_json = r#"{"type":"assistant_msg","turn_id":"019f0000-0000-7000-0000-000000000001","message":{"role":"assistant","parts":[{"type":"text","text":"hi there"}],"turn_id":"019f0000-0000-7000-0000-000000000001"},"flow_run_id":null}"#;
2005        write_events(dir.path(), &[user_json, asst_json]);
2006        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
2007        assert_eq!(msgs.len(), 2, "should load both messages from old format");
2008        assert_eq!(msgs[0].text_concat(), "hello");
2009        assert_eq!(msgs[1].text_concat(), "hi there");
2010    }
2011
2012    #[test]
2013    fn replay_messages_from_old_format_with_null_fields() {
2014        let dir = TempDir::new().unwrap();
2015        // Old JSON with null turn_id / flow_run_id (graceful parse)
2016        let sys_json = r#"{"type":"system_msg","turn_id":"019f0000-0000-7000-0000-000000000003","message":{"role":"system","parts":[{"type":"text","text":"note"}],"turn_id":"019f0000-0000-7000-0000-000000000003"}}"#;
2017        write_events(dir.path(), &[sys_json]);
2018        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
2019        assert_eq!(msgs.len(), 1);
2020        assert_eq!(msgs[0].text_concat(), "note");
2021    }
2022
2023    #[test]
2024    fn replay_messages_from_applies_attachment_degrade() {
2025        let dir = TempDir::new().unwrap();
2026        let user_msg = r#"{"type":"user_msg","seq":5,"turn_id":"019f0000-0000-7000-0000-000000000001","message":{"role":"user","parts":[{"type":"image","source":{"media_type":"image/png","data":{"kind":"path","path":"/tmp/photo.png"}}},{"type":"text","text":"describe"}],"turn_id":"019f0000-0000-7000-0000-000000000001"},"ts":"2026-07-07T00:00:00Z"}"#;
2027        let degrade = r#"{"type":"attachment_degraded","seq":6,"turn_id":null,"flow_run_id":null,"message_seq":5,"part_index":0,"file_basename":"photo.png","reason":"image_too_large","ts":"2026-07-07T00:00:01Z"}"#;
2028        write_events(dir.path(), &[user_msg, degrade]);
2029        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
2030        assert_eq!(msgs.len(), 1, "only the user message (patched)");
2031        assert_eq!(msgs[0].parts.len(), 2);
2032        match &msgs[0].parts[0] {
2033            crate::message::MessagePart::Text { text } => {
2034                assert!(text.contains("photo.png"), "expected basename: {text}");
2035                assert!(text.contains("image_too_large"), "expected reason: {text}");
2036                assert!(
2037                    text.starts_with("[attachment unavailable"),
2038                    "expected stub prefix: {text}"
2039                );
2040            }
2041            other => panic!("expected Text stub, got {other:?}"),
2042        }
2043        assert!(
2044            matches!(msgs[0].parts[1], crate::message::MessagePart::Text { .. }),
2045            "second part should remain text"
2046        );
2047    }
2048
2049    #[test]
2050    fn replay_messages_from_degrade_before_message_is_noop() {
2051        let dir = TempDir::new().unwrap();
2052        // Degrade event appears BEFORE the message it references (should not crash)
2053        let degrade = r#"{"type":"attachment_degraded","seq":1,"turn_id":null,"flow_run_id":null,"message_seq":99,"part_index":0,"file_basename":"x.png","reason":"test","ts":"2026-07-07T00:00:00Z"}"#;
2054        let user_msg = r#"{"type":"user_msg","seq":2,"turn_id":"019f0000-0000-7000-0000-000000000001","message":{"role":"user","parts":[{"type":"image","source":{"media_type":"image/png","data":{"kind":"path","path":"/tmp/x.png"}}}],"turn_id":"019f0000-0000-7000-0000-000000000001"},"ts":"2026-07-07T00:00:01Z"}"#;
2055        write_events(dir.path(), &[degrade, user_msg]);
2056        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
2057        assert_eq!(msgs.len(), 1);
2058        // Image part preserved — degrade referenced unknown message_seq
2059        assert!(
2060            matches!(msgs[0].parts[0], crate::message::MessagePart::Image { .. }),
2061            "image should remain when degrade targets unknown seq"
2062        );
2063    }
2064
2065    #[test]
2066    fn replay_messages_from_degrade_wrong_seq_leaves_image() {
2067        let dir = TempDir::new().unwrap();
2068        let user_msg = r#"{"type":"user_msg","seq":1,"turn_id":"019f0000-0000-7000-0000-000000000001","message":{"role":"user","parts":[{"type":"image","source":{"media_type":"image/png","data":{"kind":"path","path":"/tmp/x.png"}}}],"turn_id":"019f0000-0000-7000-0000-000000000001"},"ts":"2026-07-07T00:00:00Z"}"#;
2069        // Degrade references wrong message_seq (2, but message has seq 1)
2070        let degrade = r#"{"type":"attachment_degraded","seq":2,"turn_id":null,"flow_run_id":null,"message_seq":2,"part_index":0,"file_basename":"x.png","reason":"test","ts":"2026-07-07T00:00:01Z"}"#;
2071        write_events(dir.path(), &[user_msg, degrade]);
2072        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
2073        assert_eq!(msgs.len(), 1);
2074        assert!(
2075            matches!(msgs[0].parts[0], crate::message::MessagePart::Image { .. }),
2076            "image should remain when degrade targets wrong seq"
2077        );
2078    }
2079
2080    #[test]
2081    fn replay_messages_from_applies_context_compact() {
2082        let dir = TempDir::new().unwrap();
2083        let user1 = r#"{"type":"user_msg","seq":1,"turn_id":"019f0000-0000-7000-0000-000000000001","message":{"role":"user","parts":[{"type":"text","text":"old u1"}],"turn_id":"019f0000-0000-7000-0000-000000000001"},"ts":"2026-07-07T00:00:00Z"}"#;
2084        let asst1 = r#"{"type":"assistant_msg","seq":2,"turn_id":"019f0000-0000-7000-0000-000000000001","message":{"role":"assistant","parts":[{"type":"text","text":"old a1"}],"turn_id":"019f0000-0000-7000-0000-000000000001"},"flow_run_id":null,"ts":"2026-07-07T00:00:01Z"}"#;
2085        let user2 = r#"{"type":"user_msg","seq":3,"turn_id":"019f0000-0000-7000-0000-000000000002","message":{"role":"user","parts":[{"type":"text","text":"old u2"}],"turn_id":"019f0000-0000-7000-0000-000000000002"},"ts":"2026-07-07T00:00:02Z"}"#;
2086        // replacement message (compact summary)
2087        let summary = r#"{"type":"system_msg","seq":4,"turn_id":"019f0000-0000-7000-0000-000000000002","message":{"role":"system","parts":[{"type":"compact_summary","summary":"two messages compacted","seq_start":0,"seq_end":1,"count":2}],"turn_id":"019f0000-0000-7000-0000-000000000002"},"ts":"2026-07-07T00:00:03Z"}"#;
2088        let compact = r#"{"type":"context_compact","seq":5,"session_id":"sess","before_tokens":200,"after_tokens":50,"compacted_range_start":0,"compacted_range_end":1,"summary_text":"two messages compacted","replacement_msg_seq":4,"ts":"2026-07-07T00:00:04Z"}"#;
2089        let after = r#"{"type":"user_msg","seq":6,"turn_id":"019f0000-0000-7000-0000-000000000003","message":{"role":"user","parts":[{"type":"text","text":"after compact"}],"turn_id":"019f0000-0000-7000-0000-000000000003"},"ts":"2026-07-07T00:00:05Z"}"#;
2090        write_events(dir.path(), &[user1, asst1, user2, summary, compact, after]);
2091        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
2092        // compact range 0-1 removes user1+asst1; user2 (outside range) + summary + after = 3
2093        assert_eq!(msgs.len(), 3, "compact summary + user2 + after compact");
2094        assert!(
2095            matches!(
2096                msgs[0].parts[0],
2097                crate::message::MessagePart::CompactSummary { .. }
2098            ),
2099            "first should be compact summary"
2100        );
2101        if let crate::message::MessagePart::CompactSummary { summary, .. } = &msgs[0].parts[0] {
2102            assert_eq!(summary, "two messages compacted");
2103        }
2104        assert_eq!(msgs[1].text_concat(), "old u2");
2105        assert_eq!(msgs[2].text_concat(), "after compact");
2106    }
2107
2108    #[test]
2109    fn replay_messages_from_no_replacement_seq_ignores_compact() {
2110        let dir = TempDir::new().unwrap();
2111        let user1 = r#"{"type":"user_msg","seq":1,"turn_id":"019f0000-0000-7000-0000-000000000001","message":{"role":"user","parts":[{"type":"text","text":"hello"}],"turn_id":"019f0000-0000-7000-0000-000000000001"},"ts":"2026-07-07T00:00:00Z"}"#;
2112        // context_compact with no replacement_msg_seq → should be ignored
2113        let compact = r#"{"type":"context_compact","seq":2,"session_id":"sess","before_tokens":200,"after_tokens":50,"compacted_range_start":0,"compacted_range_end":0,"summary_text":"ignored","replacement_msg_seq":null,"ts":"2026-07-07T00:00:01Z"}"#;
2114        write_events(dir.path(), &[user1, compact]);
2115        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
2116        assert_eq!(msgs.len(), 1, "compact without replacement seq is ignored");
2117        assert_eq!(msgs[0].text_concat(), "hello");
2118    }
2119
2120    #[test]
2121    fn replay_messages_from_compact_after_no_change_ignored() {
2122        let dir = TempDir::new().unwrap();
2123        let user1 = r#"{"type":"user_msg","seq":1,"turn_id":"019f0000-0000-7000-0000-000000000001","message":{"role":"user","parts":[{"type":"text","text":"hello"}],"turn_id":"019f0000-0000-7000-0000-000000000001"},"ts":"2026-07-07T00:00:00Z"}"#;
2124        let summary = r#"{"type":"system_msg","seq":2,"turn_id":"019f0000-0000-7000-0000-000000000001","message":{"role":"system","parts":[{"type":"compact_summary","summary":"no change","seq_start":0,"seq_end":0,"count":1}],"turn_id":"019f0000-0000-7000-0000-000000000001"},"ts":"2026-07-07T00:00:01Z"}"#;
2125        // after_tokens >= before_tokens → compaction is no-op
2126        let compact = r#"{"type":"context_compact","seq":3,"session_id":"sess","before_tokens":50,"after_tokens":100,"compacted_range_start":0,"compacted_range_end":0,"summary_text":"no change","replacement_msg_seq":2,"ts":"2026-07-07T00:00:02Z"}"#;
2127        write_events(dir.path(), &[user1, summary, compact]);
2128        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
2129        assert_eq!(msgs.len(), 2, "compact with after>=before is ignored");
2130    }
2131
2132    #[test]
2133    fn replay_messages_from_missing_file_returns_empty() {
2134        let dir = TempDir::new().unwrap();
2135        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
2136        assert!(msgs.is_empty());
2137    }
2138
2139    #[test]
2140    fn replay_messages_from_empty_file_returns_empty() {
2141        let dir = TempDir::new().unwrap();
2142        write_events(dir.path(), &[]);
2143        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
2144        assert!(msgs.is_empty());
2145    }
2146
2147    #[test]
2148    fn replay_all_messages_with_seq_includes_compacted() {
2149        let dir = TempDir::new().unwrap();
2150        let user1 = r#"{"type":"user_msg","seq":1,"turn_id":"019f0000-0000-7000-0000-000000000001","message":{"role":"user","parts":[{"type":"text","text":"old"}],"turn_id":"019f0000-0000-7000-0000-000000000001"},"ts":"2026-07-07T00:00:00Z"}"#;
2151        let summary = r#"{"type":"system_msg","seq":4,"turn_id":"019f0000-0000-7000-0000-000000000002","message":{"role":"system","parts":[{"type":"compact_summary","summary":"s","seq_start":0,"seq_end":0,"count":1}],"turn_id":"019f0000-0000-7000-0000-000000000002"},"ts":"2026-07-07T00:00:01Z"}"#;
2152        let compact = r#"{"type":"context_compact","seq":5,"session_id":"sess","before_tokens":200,"after_tokens":50,"compacted_range_start":0,"compacted_range_end":0,"summary_text":"s","replacement_msg_seq":4,"ts":"2026-07-07T00:00:02Z"}"#;
2153        write_events(dir.path(), &[user1, summary, compact]);
2154        let all = replay_all_messages_with_seq(&dir.path().join("events.jsonl")).unwrap();
2155        // all includes both user1 and summary — compaction NOT applied
2156        assert_eq!(all.len(), 2, "all messages preserved (no compaction)");
2157        assert_eq!(all[0].1.text_concat(), "old");
2158    }
2159}