Skip to main content

atman_runtime/
session.rs

1use std::collections::{HashMap, VecDeque};
2use std::path::{Path, PathBuf};
3use std::sync::Mutex;
4
5use tokio::sync::{broadcast, watch};
6use tokio_util::sync::CancellationToken;
7use uuid::Uuid;
8
9use crate::event::{Event, EventSink, FlowRunId, TurnId};
10#[cfg(test)]
11use crate::event_log::reader::replay_context_snapshot_from;
12use crate::event_log::replay::{SessionReplay, TranscriptReplayObserver};
13use crate::event_writer::EventWriter;
14use crate::injection::{Injection, InjectionId, InjectionState};
15use crate::message::{Message, MessageRole};
16use crate::projection::message_window::replay_transcript_from;
17#[cfg(test)]
18use crate::projection::message_window::{
19    TranscriptEntry, replay_all_messages_with_seq, replay_messages_from,
20};
21use crate::stream::StreamFrame;
22
23#[derive(Debug, Clone, PartialEq, Eq, Hash)]
24pub struct SessionId(pub Uuid);
25
26fn is_auto_name_threshold(mut count: u64) -> bool {
27    if count < 3 {
28        return false;
29    }
30    while count % 3 == 0 {
31        count /= 3;
32    }
33    count == 1
34}
35
36impl SessionId {
37    pub fn now() -> Self {
38        Self(Uuid::new_v4())
39    }
40
41    pub fn parse(s: &str) -> Result<Self, uuid::Error> {
42        Uuid::parse_str(s).map(Self)
43    }
44}
45
46impl std::fmt::Display for SessionId {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        self.0.fmt(f)
49    }
50}
51
52type WatchKeepalive = (
53    watch::Receiver<ContextSnapshot>,
54    watch::Receiver<Option<String>>,
55    watch::Receiver<usize>,
56    watch::Receiver<Vec<crate::memory::todo::Todo>>,
57    watch::Receiver<Vec<crate::memory::plan::Plan>>,
58);
59
60#[derive(Debug)]
61pub struct TurnState {
62    pub current_turn: Mutex<Option<TurnId>>,
63    pub flow_cancel: Mutex<CancellationToken>,
64    pub streamed: std::sync::atomic::AtomicBool,
65}
66
67impl TurnState {
68    fn new() -> Self {
69        Self {
70            current_turn: Mutex::new(None),
71            flow_cancel: Mutex::new(CancellationToken::new()),
72            streamed: std::sync::atomic::AtomicBool::new(false),
73        }
74    }
75}
76
77pub struct WatchHub {
78    pub stream_tx: broadcast::Sender<StreamFrame>,
79    pub context: watch::Sender<ContextSnapshot>,
80    pub goal: watch::Sender<Option<String>>,
81    pub attach: watch::Sender<usize>,
82    pub todos: watch::Sender<Vec<crate::memory::todo::Todo>>,
83    pub plans: watch::Sender<Vec<crate::memory::plan::Plan>>,
84    _keepalive: WatchKeepalive,
85}
86
87pub struct CompactionState {
88    pub manual_pending: std::sync::atomic::AtomicBool,
89    pub model_window_tokens: std::sync::atomic::AtomicU64,
90    pub review_mode: Mutex<CompactReviewMode>,
91    pub lock: std::sync::Arc<tokio::sync::Mutex<()>>,
92    last_context_usage: Mutex<LastContextUsageStore>,
93    last_context_prefix: Mutex<crate::context_plan::ContextPrefixTracker>,
94    context_epoch: Mutex<Option<String>>,
95}
96
97impl CompactionState {
98    fn new() -> Self {
99        Self {
100            manual_pending: std::sync::atomic::AtomicBool::new(false),
101            model_window_tokens: std::sync::atomic::AtomicU64::new(0),
102            review_mode: Mutex::new(CompactReviewMode::default()),
103            lock: std::sync::Arc::new(tokio::sync::Mutex::new(())),
104            last_context_usage: Mutex::new(LastContextUsageStore::default()),
105            last_context_prefix: Mutex::new(crate::context_plan::ContextPrefixTracker::default()),
106            context_epoch: Mutex::new(None),
107        }
108    }
109
110    fn restore_context_epoch(&self, epoch: Option<String>) {
111        *self
112            .context_epoch
113            .lock()
114            .expect("context epoch lock poisoned") = epoch;
115    }
116
117    fn update_context_epoch(&self, messages: &[Message]) {
118        self.restore_context_epoch(Some(checkpoint_epoch_digest(messages)));
119    }
120
121    fn context_epoch(&self) -> Option<String> {
122        self.context_epoch
123            .lock()
124            .expect("context epoch lock poisoned")
125            .clone()
126    }
127}
128
129fn checkpoint_epoch_digest(messages: &[Message]) -> String {
130    let bytes = serde_json::to_vec(messages).expect("checkpoint messages must serialize");
131    format!("blake3:{}", blake3::hash(&bytes).to_hex())
132}
133
134fn replayed_checkpoint_epoch(messages: &[(u64, Message)]) -> Option<String> {
135    let checkpoint = messages
136        .iter()
137        .filter(|(seq, _)| *seq > u64::MAX / 2)
138        .map(|(_, message)| message.clone())
139        .collect::<Vec<_>>();
140    (!checkpoint.is_empty()).then(|| checkpoint_epoch_digest(&checkpoint))
141}
142
143const MAX_LAST_CONTEXT_USAGES: usize = 256;
144
145#[derive(Default)]
146struct LastContextUsageStore {
147    entries: HashMap<crate::context_plan::ContextUsageKey, crate::context_plan::ContextUsageRecord>,
148    order: VecDeque<crate::context_plan::ContextUsageKey>,
149}
150
151impl LastContextUsageStore {
152    fn insert(
153        &mut self,
154        key: crate::context_plan::ContextUsageKey,
155        record: crate::context_plan::ContextUsageRecord,
156    ) {
157        self.order.retain(|existing| existing != &key);
158        self.order.push_back(key.clone());
159        self.entries.insert(key, record);
160        while self.entries.len() > MAX_LAST_CONTEXT_USAGES {
161            if let Some(oldest) = self.order.pop_front() {
162                self.entries.remove(&oldest);
163            }
164        }
165    }
166
167    fn get(
168        &self,
169        key: &crate::context_plan::ContextUsageKey,
170    ) -> Option<crate::context_plan::ContextUsageRecord> {
171        self.entries.get(key).cloned()
172    }
173}
174
175pub struct InteractionServices {
176    pub approval: std::sync::Arc<ApprovalRegistry>,
177    pub compact_reviews: std::sync::Arc<CompactReviewRegistry>,
178    pub forms: std::sync::Arc<FormRegistry>,
179}
180
181impl InteractionServices {
182    fn new() -> Self {
183        Self {
184            approval: std::sync::Arc::new(ApprovalRegistry::new()),
185            compact_reviews: std::sync::Arc::new(CompactReviewRegistry::new()),
186            forms: std::sync::Arc::new(FormRegistry::new()),
187        }
188    }
189}
190
191pub struct Session {
192    id: SessionId,
193    dir: PathBuf,
194    writer: std::sync::Mutex<Option<EventWriter>>,
195    sink: EventSink,
196    message_stream: crate::message_stream::MessageStream,
197    messages: std::sync::Arc<std::sync::Mutex<Vec<Message>>>,
198    pub turn: TurnState,
199    pub watch: WatchHub,
200    pub watch_hub: std::sync::Arc<crate::watch::WatchHub>,
201    pub flow_registry: std::sync::Arc<crate::tools::agent_ctrl::FlowRegistry>,
202    /// Broker for the structured permission pipeline. Bound to `flow_registry`
203    /// so identity authentication and terminal cleanup observe the same runs.
204    pub permission_broker: std::sync::Arc<crate::permission::PermissionBroker>,
205    trust: watch::Sender<crate::trust::TrustConfig>,
206    trust_update_lock: std::sync::Mutex<()>,
207    /// Handle of the current root FlowRun; set per turn.
208    current_root: std::sync::Mutex<Option<String>>,
209    successful_flow_count: std::sync::atomic::AtomicU64,
210    pub compaction: CompactionState,
211    pub interactions: InteractionServices,
212    injection_queue: Mutex<Vec<Injection>>,
213    injection_tx: broadcast::Sender<Injection>,
214    last_image_user_msg: Mutex<Option<LastImageUserMsg>>,
215    pending_images: Mutex<Vec<crate::message::ImageSource>>,
216    read_files: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<std::path::PathBuf>>>,
217    output_store: std::sync::Arc<crate::tools::tool_output::OutputStore>,
218    tool_output_budget: Mutex<crate::tools::tool_output::ToolOutputBudget>,
219    fs_access_mode: Mutex<Option<crate::fs_access::FsAccessMode>>,
220    project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
221}
222
223#[derive(Debug, Clone)]
224pub struct PendingCompactReview {
225    pub review_id: String,
226    pub summary: String,
227    pub slice_preview: String,
228    pub slice_count: usize,
229    pub range_start: usize,
230    pub range_end: usize,
231    pub tokens_before: u64,
232    pub emitted_at: chrono::DateTime<chrono::Utc>,
233}
234
235#[derive(Debug, Clone)]
236pub enum CompactReviewDecision {
237    AcceptAsIs,
238    AcceptEdited { summary: String },
239    Reject,
240}
241
242pub struct CompactReviewRegistry {
243    entry: std::sync::Mutex<Option<CompactReviewEntry>>,
244    watch_tx: watch::Sender<Option<PendingCompactReview>>,
245}
246
247struct CompactReviewEntry {
248    pending: PendingCompactReview,
249    responder: tokio::sync::oneshot::Sender<CompactReviewDecision>,
250}
251
252impl Default for CompactReviewRegistry {
253    fn default() -> Self {
254        Self::new()
255    }
256}
257
258impl CompactReviewRegistry {
259    pub fn new() -> Self {
260        let (watch_tx, _) = watch::channel(None);
261        Self {
262            entry: std::sync::Mutex::new(None),
263            watch_tx,
264        }
265    }
266
267    pub fn subscribe(&self) -> watch::Receiver<Option<PendingCompactReview>> {
268        self.watch_tx.subscribe()
269    }
270
271    pub fn list_pending(&self) -> Option<PendingCompactReview> {
272        self.entry
273            .lock()
274            .unwrap()
275            .as_ref()
276            .map(|e| e.pending.clone())
277    }
278
279    pub fn subscriber_count(&self) -> usize {
280        self.watch_tx.receiver_count()
281    }
282
283    pub fn request(
284        &self,
285        pending: PendingCompactReview,
286    ) -> tokio::sync::oneshot::Receiver<CompactReviewDecision> {
287        let (tx, rx) = tokio::sync::oneshot::channel();
288        if self.watch_tx.receiver_count() == 0 {
289            let _ = tx.send(CompactReviewDecision::AcceptAsIs);
290            return rx;
291        }
292        {
293            let mut slot = self.entry.lock().unwrap();
294            if let Some(prev) = slot.take() {
295                let _ = prev.responder.send(CompactReviewDecision::Reject);
296            }
297            *slot = Some(CompactReviewEntry {
298                pending: pending.clone(),
299                responder: tx,
300            });
301        }
302        let _ = self.watch_tx.send(Some(pending));
303        rx
304    }
305
306    pub fn decide(&self, review_id: &str, decision: CompactReviewDecision) -> bool {
307        let entry = {
308            let mut slot = self.entry.lock().unwrap();
309            match slot.as_ref() {
310                Some(e) if e.pending.review_id == review_id => slot.take(),
311                _ => None,
312            }
313        };
314        match entry {
315            Some(e) => {
316                let _ = e.responder.send(decision);
317                let _ = self.watch_tx.send(None);
318                true
319            }
320            None => false,
321        }
322    }
323}
324
325#[derive(Debug, Clone)]
326pub struct PendingApproval {
327    pub tool_use_id: String,
328    pub tool_name: String,
329    pub args_preview: String,
330    pub preview: Option<String>,
331    pub level: crate::tool::ApprovalLevel,
332    pub run_id: FlowRunId,
333    pub emitted_at: chrono::DateTime<chrono::Utc>,
334}
335
336#[derive(Debug, Clone)]
337pub enum ApprovalDecision {
338    Approve,
339    Deny { reason: String },
340}
341
342pub struct FormRegistry {
343    entries: std::sync::Mutex<Vec<FormEntry>>,
344    watch_tx: watch::Sender<Vec<crate::form::PendingForm>>,
345}
346
347struct FormEntry {
348    pending: crate::form::PendingForm,
349    responder: tokio::sync::oneshot::Sender<crate::form::FormSubmission>,
350}
351
352impl Default for FormRegistry {
353    fn default() -> Self {
354        Self::new()
355    }
356}
357
358impl FormRegistry {
359    pub fn new() -> Self {
360        let (watch_tx, _) = watch::channel(Vec::new());
361        Self {
362            entries: std::sync::Mutex::new(Vec::new()),
363            watch_tx,
364        }
365    }
366
367    pub fn subscribe(&self) -> watch::Receiver<Vec<crate::form::PendingForm>> {
368        self.watch_tx.subscribe()
369    }
370
371    pub fn list_pending(&self) -> Vec<crate::form::PendingForm> {
372        self.entries
373            .lock()
374            .unwrap()
375            .iter()
376            .map(|e| e.pending.clone())
377            .collect()
378    }
379
380    pub fn subscriber_count(&self) -> usize {
381        self.watch_tx.receiver_count()
382    }
383
384    // No TUI attached → auto-cancel so flows don't hang forever. Otherwise
385    // enqueue and hand a receiver back to the caller.
386    pub fn request(
387        &self,
388        pending: crate::form::PendingForm,
389    ) -> tokio::sync::oneshot::Receiver<crate::form::FormSubmission> {
390        let (tx, rx) = tokio::sync::oneshot::channel();
391        if self.watch_tx.receiver_count() == 0 {
392            let _ = tx.send(crate::form::FormSubmission::Rejected);
393            return rx;
394        }
395        {
396            let mut entries = self.entries.lock().unwrap();
397            entries.push(FormEntry {
398                pending: pending.clone(),
399                responder: tx,
400            });
401        }
402        self.broadcast_snapshot();
403        rx
404    }
405
406    pub fn submit(&self, form_id: &str, submission: crate::form::FormSubmission) -> bool {
407        let entry = {
408            let mut entries = self.entries.lock().unwrap();
409            let pos = entries.iter().position(|e| e.pending.form_id == form_id);
410            pos.map(|p| entries.remove(p))
411        };
412        match entry {
413            Some(e) => {
414                let _ = e.responder.send(submission);
415                self.broadcast_snapshot();
416                true
417            }
418            None => false,
419        }
420    }
421
422    pub fn cancel(&self, form_id: &str) -> bool {
423        self.submit(form_id, crate::form::FormSubmission::Rejected)
424    }
425
426    pub fn cancel_all(&self) {
427        let drained: Vec<FormEntry> = {
428            let mut entries = self.entries.lock().unwrap();
429            std::mem::take(&mut *entries)
430        };
431        for e in drained {
432            let _ = e.responder.send(crate::form::FormSubmission::Rejected);
433        }
434        self.broadcast_snapshot();
435    }
436
437    pub fn promote(&self, form_id: &str) {
438        let mut entries = self.entries.lock().unwrap();
439        if let Some(pos) = entries.iter().position(|e| e.pending.form_id == form_id) {
440            if pos == 0 {
441                return;
442            }
443            let entry = entries.remove(pos);
444            entries.insert(0, entry);
445        }
446        drop(entries);
447        self.broadcast_snapshot();
448    }
449
450    fn broadcast_snapshot(&self) {
451        let snap = self
452            .entries
453            .lock()
454            .unwrap()
455            .iter()
456            .map(|e| e.pending.clone())
457            .collect();
458        let _ = self.watch_tx.send(snap);
459    }
460}
461
462pub struct ApprovalRegistry {
463    entries: std::sync::Mutex<Vec<ApprovalEntry>>,
464    watch_tx: watch::Sender<Vec<PendingApproval>>,
465    next_entry_id: std::sync::atomic::AtomicU64,
466}
467
468struct ApprovalEntry {
469    entry_id: u64,
470    pending: PendingApproval,
471    responder: tokio::sync::oneshot::Sender<ApprovalDecision>,
472}
473
474/// Identifies one queued approval entry. Providers can reuse a `tool_use_id`
475/// across concurrent runs, so cleanup must target the exact entry it created
476/// rather than the first entry that happens to share the id.
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub struct ApprovalTicket(u64);
479
480impl Default for ApprovalRegistry {
481    fn default() -> Self {
482        Self::new()
483    }
484}
485
486impl ApprovalRegistry {
487    pub fn new() -> Self {
488        let (watch_tx, _) = watch::channel(Vec::new());
489        Self {
490            entries: std::sync::Mutex::new(Vec::new()),
491            watch_tx,
492            next_entry_id: std::sync::atomic::AtomicU64::new(0),
493        }
494    }
495
496    pub fn subscribe(&self) -> watch::Receiver<Vec<PendingApproval>> {
497        self.watch_tx.subscribe()
498    }
499
500    pub fn has_subscribers(&self) -> bool {
501        self.watch_tx.receiver_count() > 0
502    }
503
504    pub fn list_pending(&self) -> Vec<PendingApproval> {
505        self.entries
506            .lock()
507            .unwrap()
508            .iter()
509            .map(|e| e.pending.clone())
510            .collect()
511    }
512
513    pub fn request(
514        &self,
515        pending: PendingApproval,
516    ) -> tokio::sync::oneshot::Receiver<ApprovalDecision> {
517        self.request_tracked(pending).1
518    }
519
520    /// Same as [`Self::request`], but also returns a ticket that identifies this
521    /// exact queue entry for later [`Self::cancel`].
522    pub fn request_tracked(
523        &self,
524        pending: PendingApproval,
525    ) -> (
526        Option<ApprovalTicket>,
527        tokio::sync::oneshot::Receiver<ApprovalDecision>,
528    ) {
529        let (tx, rx) = tokio::sync::oneshot::channel();
530        let entry_id = self
531            .next_entry_id
532            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
533        {
534            let mut entries = self.entries.lock().unwrap();
535            entries.push(ApprovalEntry {
536                entry_id,
537                pending,
538                responder: tx,
539            });
540        }
541        self.broadcast_snapshot();
542        (Some(ApprovalTicket(entry_id)), rx)
543    }
544
545    /// Removes one queued entry and denies it, so a request already settled
546    /// elsewhere (broker cancellation, flow terminal) cannot linger in the UI.
547    pub fn cancel(&self, ticket: ApprovalTicket, reason: impl Into<String>) -> bool {
548        let mut entries = self.entries.lock().unwrap();
549        let Some(pos) = entries.iter().position(|e| e.entry_id == ticket.0) else {
550            return false;
551        };
552        let entry = entries.remove(pos);
553        let _ = entry.responder.send(ApprovalDecision::Deny {
554            reason: reason.into(),
555        });
556        drop(entries);
557        self.broadcast_snapshot();
558        true
559    }
560
561    pub fn decide(&self, tool_use_id: &str, decision: ApprovalDecision) -> bool {
562        let mut entries = self.entries.lock().unwrap();
563        if let Some(pos) = entries
564            .iter()
565            .position(|e| e.pending.tool_use_id == tool_use_id)
566        {
567            let entry = entries.remove(pos);
568            let _ = entry.responder.send(decision);
569            drop(entries);
570            self.broadcast_snapshot();
571            true
572        } else {
573            false
574        }
575    }
576
577    pub fn decide_all(&self, decision: ApprovalDecision) -> usize {
578        let mut entries = self.entries.lock().unwrap();
579        let count = entries.len();
580        for entry in entries.drain(..) {
581            let _ = entry.responder.send(decision.clone());
582        }
583        drop(entries);
584        self.broadcast_snapshot();
585        count
586    }
587
588    fn broadcast_snapshot(&self) {
589        let snapshot = self
590            .entries
591            .lock()
592            .unwrap()
593            .iter()
594            .map(|e| e.pending.clone())
595            .collect();
596        let _ = self.watch_tx.send(snapshot);
597    }
598}
599type ImagePart = (usize, String);
600
601#[derive(Debug, Clone)]
602struct LastImageUserMsg {
603    message_seq: u64,
604    message_turn_id: crate::event::TurnId,
605    images: Vec<ImagePart>,
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
609pub enum CompactReviewMode {
610    Always,
611    #[default]
612    ManualOnly,
613    Never,
614}
615
616impl CompactReviewMode {
617    pub fn parse(s: &str) -> Option<Self> {
618        match s.trim() {
619            "always" => Some(Self::Always),
620            "manual-only" | "manual_only" => Some(Self::ManualOnly),
621            "never" => Some(Self::Never),
622            _ => None,
623        }
624    }
625
626    pub fn should_review(self, forced: bool) -> bool {
627        match self {
628            Self::Always => true,
629            Self::ManualOnly => forced,
630            Self::Never => false,
631        }
632    }
633}
634
635#[derive(Debug, Clone, PartialEq, Eq)]
636pub struct CompactResult {
637    pub before_tokens: u64,
638    pub after_tokens: u64,
639    pub compacted_start: usize,
640    pub compacted_end: usize,
641}
642
643#[derive(Debug, Clone, Default, PartialEq)]
644pub struct ContextUsageBucket {
645    pub provider: String,
646    pub model: String,
647    pub call_purpose: crate::context_plan::ContextCallPurpose,
648    pub call_scope: crate::context_plan::ContextCallScope,
649    pub calls: u64,
650    /// Total prompt input, including cache hits.
651    pub tokens_in: u64,
652    pub tokens_out: u64,
653    pub cache_read: u64,
654    pub cache_write: u64,
655}
656
657impl ContextUsageBucket {
658    pub fn is_primary(&self) -> bool {
659        self.call_purpose == crate::context_plan::ContextCallPurpose::General
660            && self.call_scope == crate::context_plan::ContextCallScope::Root
661    }
662}
663
664#[derive(Debug, Clone, Default, PartialEq)]
665pub struct ContextSnapshot {
666    pub model: String,
667    pub provider: String,
668    pub tokens_in: u64,
669    pub tokens_out: u64,
670    pub cost_usd: f64,
671    pub mcp_servers: Vec<crate::mcp::McpServerStatus>,
672    pub memory_recent_count: u16,
673    pub window_tokens: u64,
674    pub window_budget: u64,
675    pub cache_read: u64,
676    pub cache_write: u64,
677    pub last_ttft_ms: u64,
678    pub last_tokens_per_sec: f64,
679    pub usage_buckets: Vec<ContextUsageBucket>,
680}
681
682impl ContextSnapshot {
683    pub fn primary_usage(&self) -> Option<&ContextUsageBucket> {
684        self.usage_buckets.iter().find(|bucket| {
685            bucket.is_primary()
686                && bucket.model == self.model
687                && (self.provider.is_empty() || bucket.provider == self.provider)
688        })
689    }
690}
691
692#[derive(Debug, thiserror::Error)]
693pub enum SessionOpenError {
694    #[error("invalid session id `{sid}` (want a UUID)")]
695    InvalidId { sid: String },
696    #[error("session `{sid}` not found at {}", dir.display())]
697    NotFound { sid: String, dir: PathBuf },
698    #[error("session writer init: {0}")]
699    WriterInit(#[source] std::io::Error),
700    #[error("replay {}: {source}", path.display())]
701    Replay {
702        path: PathBuf,
703        #[source]
704        source: std::io::Error,
705    },
706    #[error("load session trust {}: {source}", path.display())]
707    Trust {
708        path: PathBuf,
709        #[source]
710        source: std::io::Error,
711    },
712}
713
714#[derive(Debug, thiserror::Error)]
715pub enum TrustUpdateError {
716    #[error("persist session trust: {0}")]
717    Session(#[source] std::io::Error),
718    #[error("persist global trust: {0}")]
719    Global(#[source] std::io::Error),
720    #[error(
721        "persist global trust failed ({global}); rollback session trust also failed ({rollback})"
722    )]
723    RollbackFailed {
724        global: std::io::Error,
725        rollback: std::io::Error,
726    },
727}
728
729fn trust_path(dir: &Path) -> PathBuf {
730    dir.join("trust.json")
731}
732
733fn read_trust(dir: &Path) -> std::io::Result<crate::trust::TrustConfig> {
734    let path = trust_path(dir);
735    let bytes = std::fs::read(&path)?;
736    serde_json::from_slice(&bytes)
737        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))
738}
739
740fn write_trust(dir: &Path, trust: &crate::trust::TrustConfig) -> std::io::Result<()> {
741    if dir.as_os_str().is_empty() {
742        return Ok(());
743    }
744    let bytes = serde_json::to_vec_pretty(trust)
745        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
746    let temp = dir.join(format!(".trust.{}.tmp", std::process::id()));
747    std::fs::write(&temp, bytes)?;
748    if let Err(error) = std::fs::rename(&temp, trust_path(dir)) {
749        let _ = std::fs::remove_file(temp);
750        return Err(error);
751    }
752    Ok(())
753}
754
755fn load_goal(dir: &Path) -> Option<String> {
756    if dir.as_os_str().is_empty() {
757        return None;
758    }
759    let store = crate::memory::goal::GoalStore::at(dir);
760    match store.get() {
761        Ok(s) if !s.is_empty() => Some(s),
762        _ => None,
763    }
764}
765
766#[derive(serde::Serialize, serde::Deserialize, Default)]
767struct PersistedContextState {
768    #[serde(default)]
769    model: String,
770    #[serde(default)]
771    window_tokens: u64,
772    #[serde(default)]
773    window_budget: u64,
774}
775
776impl PersistedContextState {
777    fn path(dir: &Path) -> PathBuf {
778        dir.join("context_state.json")
779    }
780
781    fn load(dir: &Path) -> Self {
782        match std::fs::read_to_string(Self::path(dir)) {
783            Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
784            Err(_) => Self::default(),
785        }
786    }
787
788    fn save(&self, dir: &Path) {
789        if dir.as_os_str().is_empty() {
790            return;
791        }
792        if let Ok(json) = serde_json::to_string_pretty(self) {
793            let _ = std::fs::write(Self::path(dir), &json);
794        }
795    }
796}
797
798/// Builds the flow registry and its permission broker as one unit. The broker
799/// authenticates requesters against this exact registry, so both must be the
800/// same Arc for every session constructor.
801fn new_permission_pipeline(
802    sink: &EventSink,
803    stream: &broadcast::Sender<StreamFrame>,
804) -> (
805    std::sync::Arc<crate::tools::agent_ctrl::FlowRegistry>,
806    std::sync::Arc<crate::permission::PermissionBroker>,
807) {
808    let flow_registry = std::sync::Arc::new(crate::tools::agent_ctrl::FlowRegistry::new());
809    let broker = crate::permission::PermissionBroker::shared(std::sync::Arc::clone(&flow_registry));
810    broker.set_audit_projector(crate::permission_audit::PermissionAuditProjector::new(
811        sink.clone(),
812        stream.clone(),
813    ));
814    (flow_registry, broker)
815}
816
817fn default_project_index(root: &Path) -> Option<std::sync::Arc<crate::index::AnchorIndex>> {
818    match crate::index::AnchorIndex::open_project(root) {
819        Ok(idx) => Some(std::sync::Arc::new(idx)),
820        Err(e) => {
821            crate::notify!(
822                warn,
823                "project index unavailable at {} — history search disabled: {e}",
824                root.display()
825            );
826            None
827        }
828    }
829}
830
831impl Session {
832    pub fn open(root: impl AsRef<Path>) -> std::io::Result<Self> {
833        Self::open_with_redactor(root, None)
834    }
835
836    pub fn open_with_trust(
837        root: impl AsRef<Path>,
838        trust: crate::trust::TrustConfig,
839    ) -> std::io::Result<Self> {
840        let root_ref = root.as_ref();
841        let project_index = default_project_index(root_ref);
842        Self::open_with_context_and_trust(root_ref, None, project_index, trust)
843    }
844
845    pub fn open_with_redactor(
846        root: impl AsRef<Path>,
847        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
848    ) -> std::io::Result<Self> {
849        let root_ref = root.as_ref();
850        let project_index = default_project_index(root_ref);
851        Self::open_with_context_and_trust(
852            root_ref,
853            redactor,
854            project_index,
855            crate::trust::TrustConfig::default(),
856        )
857    }
858
859    pub fn open_with_context_and_trust(
860        root: impl AsRef<Path>,
861        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
862        project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
863        trust: crate::trust::TrustConfig,
864    ) -> std::io::Result<Self> {
865        let session = Self::open_with_context_inner(root, redactor, project_index)?;
866        write_trust(&session.dir, &trust)?;
867        session.trust.send_replace(trust);
868        Ok(session)
869    }
870
871    pub fn open_with_context(
872        root: impl AsRef<Path>,
873        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
874        project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
875    ) -> std::io::Result<Self> {
876        Self::open_with_context_and_trust(
877            root,
878            redactor,
879            project_index,
880            crate::trust::TrustConfig::default(),
881        )
882    }
883
884    fn open_with_context_inner(
885        root: impl AsRef<Path>,
886        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
887        project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
888    ) -> std::io::Result<Self> {
889        let id = SessionId::now();
890        let dir = root.as_ref().join("sessions").join(id.to_string());
891        if let Some(ls) = crate::notify::log_sink() {
892            ls.set_session_id(Some(id.to_string()));
893        }
894        let writer = EventWriter::spawn_full(
895            &dir,
896            redactor.clone(),
897            project_index.clone(),
898            Some(id.to_string()),
899        )?;
900        if let Err(e) = crate::session_meta::SessionMeta::from_cwd().save(&dir) {
901            crate::notify!(error, "session meta write failed: {e}");
902        }
903        let mut sink = EventSink::new().with_forwarder(writer.sender());
904        if let Some(r) = redactor {
905            sink = sink.with_redactor(r);
906        }
907        let (injection_tx, _) = broadcast::channel(32);
908        let (stream_tx, _) = broadcast::channel(2048);
909        let (context_watch, context_rx) = watch::channel(ContextSnapshot::default());
910        let (goal_watch, goal_rx) = watch::channel(None);
911        let (attach_watch, attach_rx) = watch::channel(0);
912        let (todos_watch, todos_rx) = watch::channel(Vec::new());
913        let (plans_watch, plans_rx) = watch::channel(Vec::new());
914        let events_handle = sink.events_handle();
915        let output_store = std::sync::Arc::new(crate::tools::tool_output::OutputStore::at(&dir));
916        let (flow_registry, permission_broker) = new_permission_pipeline(&sink, &stream_tx);
917        Ok(Self {
918            id,
919            dir,
920            writer: std::sync::Mutex::new(Some(writer)),
921            sink,
922            message_stream: crate::message_stream::MessageStream::new(events_handle),
923            messages: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
924            output_store: output_store.clone(),
925            tool_output_budget: Mutex::new(Default::default()),
926            turn: TurnState::new(),
927            watch: WatchHub {
928                stream_tx,
929                context: context_watch,
930                goal: goal_watch,
931                attach: attach_watch,
932                todos: todos_watch,
933                plans: plans_watch,
934                _keepalive: (context_rx, goal_rx, attach_rx, todos_rx, plans_rx),
935            },
936            watch_hub: std::sync::Arc::new(crate::watch::WatchHub::new()),
937            flow_registry,
938            permission_broker,
939            trust: watch::channel(crate::trust::TrustConfig::default()).0,
940            trust_update_lock: std::sync::Mutex::new(()),
941            current_root: std::sync::Mutex::new(None),
942            successful_flow_count: std::sync::atomic::AtomicU64::new(0),
943            compaction: CompactionState::new(),
944            interactions: InteractionServices::new(),
945            injection_queue: Mutex::new(Vec::new()),
946            injection_tx,
947            last_image_user_msg: Mutex::new(None),
948            pending_images: Mutex::new(Vec::new()),
949            read_files: std::sync::Arc::new(
950                std::sync::Mutex::new(std::collections::HashSet::new()),
951            ),
952            fs_access_mode: Mutex::new(None),
953            project_index,
954        })
955    }
956
957    pub fn open_existing(root: impl AsRef<Path>, sid: &str) -> Result<Self, SessionOpenError> {
958        Self::open_existing_with_redactor(root, sid, None)
959    }
960
961    pub fn open_existing_with_trust(
962        root: impl AsRef<Path>,
963        sid: &str,
964        trust: crate::trust::TrustConfig,
965    ) -> Result<Self, SessionOpenError> {
966        let root_ref = root.as_ref();
967        let project_index = default_project_index(root_ref);
968        Self::open_existing_with_context_and_trust(root_ref, sid, None, project_index, trust)
969    }
970
971    pub fn open_existing_with_redactor(
972        root: impl AsRef<Path>,
973        sid: &str,
974        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
975    ) -> Result<Self, SessionOpenError> {
976        let project_index = default_project_index(root.as_ref());
977        Self::open_existing_with_context_and_trust(
978            root,
979            sid,
980            redactor,
981            project_index,
982            crate::trust::TrustConfig::default(),
983        )
984    }
985
986    pub fn open_existing_with_context_and_trust(
987        root: impl AsRef<Path>,
988        sid: &str,
989        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
990        project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
991        global_trust: crate::trust::TrustConfig,
992    ) -> Result<Self, SessionOpenError> {
993        Self::open_existing_with_context_trust_and_observer(
994            root,
995            sid,
996            redactor,
997            project_index,
998            global_trust,
999            None,
1000        )
1001    }
1002
1003    pub fn open_existing_with_replay_observer(
1004        root: impl AsRef<Path>,
1005        sid: &str,
1006        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
1007        project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
1008        global_trust: crate::trust::TrustConfig,
1009        observer: &mut dyn TranscriptReplayObserver,
1010    ) -> Result<Self, SessionOpenError> {
1011        Self::open_existing_with_context_trust_and_observer(
1012            root,
1013            sid,
1014            redactor,
1015            project_index,
1016            global_trust,
1017            Some(observer),
1018        )
1019    }
1020
1021    fn open_existing_with_context_trust_and_observer(
1022        root: impl AsRef<Path>,
1023        sid: &str,
1024        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
1025        project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
1026        global_trust: crate::trust::TrustConfig,
1027        observer: Option<&mut dyn TranscriptReplayObserver>,
1028    ) -> Result<Self, SessionOpenError> {
1029        let session =
1030            Self::open_existing_with_context_inner(root, sid, redactor, project_index, observer)?;
1031        let path = trust_path(&session.dir);
1032        let trust = if path.exists() {
1033            read_trust(&session.dir).map_err(|source| SessionOpenError::Trust {
1034                path: path.clone(),
1035                source,
1036            })?
1037        } else {
1038            write_trust(&session.dir, &global_trust).map_err(|source| SessionOpenError::Trust {
1039                path: path.clone(),
1040                source,
1041            })?;
1042            global_trust
1043        };
1044        session.trust.send_replace(trust);
1045        Ok(session)
1046    }
1047
1048    pub fn open_existing_with_context(
1049        root: impl AsRef<Path>,
1050        sid: &str,
1051        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
1052        project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
1053    ) -> Result<Self, SessionOpenError> {
1054        Self::open_existing_with_context_and_trust(
1055            root,
1056            sid,
1057            redactor,
1058            project_index,
1059            crate::trust::TrustConfig::default(),
1060        )
1061    }
1062
1063    fn open_existing_with_context_inner(
1064        root: impl AsRef<Path>,
1065        sid: &str,
1066        redactor: Option<std::sync::Arc<crate::redact::Redactor>>,
1067        project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
1068        observer: Option<&mut dyn TranscriptReplayObserver>,
1069    ) -> Result<Self, SessionOpenError> {
1070        let id = SessionId::parse(sid).map_err(|_| SessionOpenError::InvalidId {
1071            sid: sid.to_string(),
1072        })?;
1073        let dir = root.as_ref().join("sessions").join(id.to_string());
1074        if let Some(ls) = crate::notify::log_sink() {
1075            ls.set_session_id(Some(id.to_string()));
1076        }
1077        if !dir.exists() {
1078            return Err(SessionOpenError::NotFound {
1079                sid: sid.to_string(),
1080                dir: dir.clone(),
1081            });
1082        }
1083        let writer = EventWriter::spawn_full(
1084            &dir,
1085            redactor.clone(),
1086            project_index.clone(),
1087            Some(id.to_string()),
1088        )
1089        .map_err(SessionOpenError::WriterInit)?;
1090        let mut sink = EventSink::new().with_forwarder(writer.sender());
1091        if let Some(r) = redactor {
1092            sink = sink.with_redactor(r);
1093        }
1094        let events_path = dir.join("events.jsonl");
1095        let replay = SessionReplay::from_path(&events_path, observer)?;
1096        let initial_msgs = replay.compacted_messages;
1097        let messages = initial_msgs
1098            .iter()
1099            .map(|(_, message)| message.clone())
1100            .collect();
1101        let checkpoint_epoch = replayed_checkpoint_epoch(&initial_msgs);
1102        let all_msgs = replay.all_messages;
1103        if let Some(last_seq) = replay.last_seq {
1104            sink.restore_seq(last_seq);
1105        }
1106        let mut initial_context = replay.context;
1107        let persisted = PersistedContextState::load(&dir);
1108        if !persisted.model.is_empty() {
1109            initial_context.model = persisted.model;
1110        }
1111        initial_context.window_tokens = persisted.window_tokens;
1112        initial_context.window_budget = persisted.window_budget;
1113        let initial_goal = load_goal(&dir);
1114        let (injection_tx, _) = broadcast::channel(32);
1115        let (stream_tx, _) = broadcast::channel(2048);
1116        let (context_watch, context_rx) = watch::channel(initial_context);
1117        let (goal_watch, goal_rx) = watch::channel(initial_goal);
1118        let (attach_watch, attach_rx) = watch::channel(0);
1119        let (todos_watch, todos_rx) = watch::channel(Vec::new());
1120        let (plans_watch, plans_rx) = watch::channel(Vec::new());
1121        let events_handle = sink.events_handle();
1122        let output_store = std::sync::Arc::new(crate::tools::tool_output::OutputStore::at(&dir));
1123        let (flow_registry, permission_broker) = new_permission_pipeline(&sink, &stream_tx);
1124        Ok(Self {
1125            id,
1126            dir,
1127            writer: std::sync::Mutex::new(Some(writer)),
1128            sink,
1129            message_stream: crate::message_stream::MessageStream::with_initial(
1130                events_handle,
1131                initial_msgs,
1132                all_msgs,
1133            ),
1134            messages: std::sync::Arc::new(std::sync::Mutex::new(messages)),
1135            output_store: output_store.clone(),
1136            tool_output_budget: Mutex::new(Default::default()),
1137            turn: TurnState::new(),
1138            watch: WatchHub {
1139                stream_tx,
1140                context: context_watch,
1141                goal: goal_watch,
1142                attach: attach_watch,
1143                todos: todos_watch,
1144                plans: plans_watch,
1145                _keepalive: (context_rx, goal_rx, attach_rx, todos_rx, plans_rx),
1146            },
1147            watch_hub: std::sync::Arc::new(crate::watch::WatchHub::new()),
1148            flow_registry,
1149            permission_broker,
1150            trust: watch::channel(crate::trust::TrustConfig::default()).0,
1151            trust_update_lock: std::sync::Mutex::new(()),
1152            current_root: std::sync::Mutex::new(None),
1153            successful_flow_count: std::sync::atomic::AtomicU64::new(0),
1154            compaction: {
1155                let c = CompactionState::new();
1156                c.restore_context_epoch(checkpoint_epoch);
1157                if persisted.window_tokens > 0 {
1158                    c.model_window_tokens.store(
1159                        persisted.window_tokens,
1160                        std::sync::atomic::Ordering::Relaxed,
1161                    );
1162                }
1163                c
1164            },
1165            interactions: InteractionServices::new(),
1166            injection_queue: Mutex::new(Vec::new()),
1167            injection_tx,
1168            last_image_user_msg: Mutex::new(None),
1169            pending_images: Mutex::new(Vec::new()),
1170            read_files: std::sync::Arc::new(
1171                std::sync::Mutex::new(std::collections::HashSet::new()),
1172            ),
1173            fs_access_mode: Mutex::new(None),
1174            project_index,
1175        })
1176    }
1177
1178    pub fn open_ephemeral() -> Self {
1179        let (injection_tx, _) = broadcast::channel(32);
1180        let (stream_tx, _) = broadcast::channel(2048);
1181        let (context_watch, context_rx) = watch::channel(ContextSnapshot::default());
1182        let (goal_watch, goal_rx) = watch::channel(None);
1183        let (attach_watch, attach_rx) = watch::channel(0);
1184        let (todos_watch, todos_rx) = watch::channel(Vec::new());
1185        let (plans_watch, plans_rx) = watch::channel(Vec::new());
1186        let sink = EventSink::new();
1187        let events_handle = sink.events_handle();
1188        let output_store = std::sync::Arc::new(crate::tools::tool_output::OutputStore::default());
1189        let (flow_registry, permission_broker) = new_permission_pipeline(&sink, &stream_tx);
1190        Self {
1191            id: SessionId::now(),
1192            dir: PathBuf::new(),
1193            writer: std::sync::Mutex::new(None),
1194            sink,
1195            message_stream: crate::message_stream::MessageStream::new(events_handle),
1196            messages: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
1197            output_store: output_store.clone(),
1198            tool_output_budget: Mutex::new(Default::default()),
1199            turn: TurnState::new(),
1200            watch: WatchHub {
1201                stream_tx,
1202                context: context_watch,
1203                goal: goal_watch,
1204                attach: attach_watch,
1205                todos: todos_watch,
1206                plans: plans_watch,
1207                _keepalive: (context_rx, goal_rx, attach_rx, todos_rx, plans_rx),
1208            },
1209            watch_hub: std::sync::Arc::new(crate::watch::WatchHub::new()),
1210            flow_registry,
1211            permission_broker,
1212            trust: watch::channel(crate::trust::TrustConfig::default()).0,
1213            trust_update_lock: std::sync::Mutex::new(()),
1214            current_root: std::sync::Mutex::new(None),
1215            successful_flow_count: std::sync::atomic::AtomicU64::new(0),
1216            compaction: CompactionState::new(),
1217            interactions: InteractionServices::new(),
1218            injection_queue: Mutex::new(Vec::new()),
1219            injection_tx,
1220            last_image_user_msg: Mutex::new(None),
1221            pending_images: Mutex::new(Vec::new()),
1222            read_files: std::sync::Arc::new(
1223                std::sync::Mutex::new(std::collections::HashSet::new()),
1224            ),
1225            fs_access_mode: Mutex::new(None),
1226            project_index: None,
1227        }
1228    }
1229
1230    pub fn project_index(&self) -> Option<std::sync::Arc<crate::index::AnchorIndex>> {
1231        self.project_index.clone()
1232    }
1233
1234    pub fn approval(&self) -> std::sync::Arc<ApprovalRegistry> {
1235        self.interactions.approval.clone()
1236    }
1237
1238    pub fn permission_broker(&self) -> std::sync::Arc<crate::permission::PermissionBroker> {
1239        std::sync::Arc::clone(&self.permission_broker)
1240    }
1241
1242    pub fn trust_config(&self) -> crate::trust::TrustConfig {
1243        self.trust.borrow().clone()
1244    }
1245
1246    pub fn subscribe_trust(&self) -> watch::Receiver<crate::trust::TrustConfig> {
1247        self.trust.subscribe()
1248    }
1249
1250    pub fn update_trust(
1251        &self,
1252        trust: crate::trust::TrustConfig,
1253        persist_global: impl FnOnce(&crate::trust::TrustConfig) -> std::io::Result<()>,
1254    ) -> Result<(), TrustUpdateError> {
1255        let _guard = self.trust_update_lock.lock().unwrap();
1256        let previous = self.trust_config();
1257        write_trust(&self.dir, &trust).map_err(TrustUpdateError::Session)?;
1258        if let Err(global) = persist_global(&trust) {
1259            return match write_trust(&self.dir, &previous) {
1260                Ok(()) => Err(TrustUpdateError::Global(global)),
1261                Err(rollback) => Err(TrustUpdateError::RollbackFailed { global, rollback }),
1262            };
1263        }
1264        self.trust.send_replace(trust);
1265        Ok(())
1266    }
1267
1268    pub fn compact_reviews(&self) -> std::sync::Arc<CompactReviewRegistry> {
1269        self.interactions.compact_reviews.clone()
1270    }
1271
1272    pub fn forms(&self) -> std::sync::Arc<FormRegistry> {
1273        self.interactions.forms.clone()
1274    }
1275
1276    pub fn fs_access_mode(&self) -> Option<crate::fs_access::FsAccessMode> {
1277        *self.fs_access_mode.lock().unwrap()
1278    }
1279
1280    pub fn set_fs_access_mode(&self, mode: crate::fs_access::FsAccessMode) {
1281        *self.fs_access_mode.lock().unwrap() = Some(mode);
1282    }
1283
1284    pub fn compact_review_mode(&self) -> CompactReviewMode {
1285        *self.compaction.review_mode.lock().unwrap()
1286    }
1287
1288    pub fn set_compact_review_mode(&self, mode: CompactReviewMode) {
1289        *self.compaction.review_mode.lock().unwrap() = mode;
1290    }
1291
1292    pub fn read_files(
1293        &self,
1294    ) -> std::sync::Arc<std::sync::Mutex<std::collections::HashSet<std::path::PathBuf>>> {
1295        self.read_files.clone()
1296    }
1297
1298    pub fn output_store(&self) -> std::sync::Arc<crate::tools::tool_output::OutputStore> {
1299        self.output_store.clone()
1300    }
1301
1302    pub fn set_tool_output_budget(&self, budget: crate::tools::tool_output::ToolOutputBudget) {
1303        *self.tool_output_budget.lock().unwrap() = budget;
1304    }
1305
1306    pub fn tool_output_budget(&self) -> crate::tools::tool_output::ToolOutputBudget {
1307        *self.tool_output_budget.lock().unwrap()
1308    }
1309
1310    pub fn mark_file_read(&self, path: &std::path::Path) {
1311        if let Ok(mut set) = self.read_files.lock() {
1312            set.insert(path.to_path_buf());
1313            if let Ok(canonical) = std::fs::canonicalize(path) {
1314                set.insert(canonical);
1315            }
1316        }
1317    }
1318
1319    pub fn stream_tx(&self) -> broadcast::Sender<StreamFrame> {
1320        self.watch.stream_tx.clone()
1321    }
1322
1323    pub fn set_current_root(&self, handle: String) {
1324        *self.current_root.lock().unwrap() = Some(handle);
1325    }
1326
1327    pub fn current_root(&self) -> Option<String> {
1328        self.current_root.lock().unwrap().clone()
1329    }
1330
1331    pub fn clear_current_root(&self) {
1332        *self.current_root.lock().unwrap() = None;
1333    }
1334
1335    pub fn record_successful_flow(&self) -> Option<u64> {
1336        let count = self
1337            .successful_flow_count
1338            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1339            + 1;
1340        is_auto_name_threshold(count).then_some(count)
1341    }
1342
1343    pub fn successful_flow_count(&self) -> u64 {
1344        self.successful_flow_count
1345            .load(std::sync::atomic::Ordering::Relaxed)
1346    }
1347
1348    pub fn stream_subscribe(&self) -> broadcast::Receiver<StreamFrame> {
1349        self.watch.stream_tx.subscribe()
1350    }
1351
1352    pub fn id(&self) -> &SessionId {
1353        &self.id
1354    }
1355
1356    pub fn dir(&self) -> &Path {
1357        &self.dir
1358    }
1359
1360    pub fn transcript_since_open(&self) -> Vec<crate::projection::message_window::TranscriptEntry> {
1361        crate::event_log::replay::transcript_from_envelopes(&self.sink.snapshot_envelopes())
1362    }
1363
1364    pub fn transcript_replay(&self) -> Vec<crate::projection::message_window::TranscriptEntry> {
1365        let Some(path) = self.events_path() else {
1366            return Vec::new();
1367        };
1368        replay_transcript_from(&path).unwrap_or_default()
1369    }
1370
1371    pub fn events_path(&self) -> Option<std::path::PathBuf> {
1372        self.writer
1373            .lock()
1374            .unwrap()
1375            .as_ref()
1376            .map(|w| w.events_path().to_path_buf())
1377    }
1378
1379    pub async fn plan_system_prompt(&self) -> Option<String> {
1380        let store = crate::memory::plan::PlanStore::at(&self.dir);
1381        let plan = store.latest().await.ok().flatten()?;
1382        Some(crate::tools::plan::render_plan(&plan))
1383    }
1384
1385    pub fn goal(&self) -> Option<String> {
1386        if let Some(cached) = self.watch.goal.borrow().clone() {
1387            return Some(cached);
1388        }
1389        load_goal(&self.dir)
1390    }
1391
1392    pub fn subscribe_goal(&self) -> watch::Receiver<Option<String>> {
1393        self.watch.goal.subscribe()
1394    }
1395
1396    pub fn goal_watch(&self) -> &watch::Sender<Option<String>> {
1397        &self.watch.goal
1398    }
1399
1400    pub fn subscribe_context(&self) -> watch::Receiver<ContextSnapshot> {
1401        self.watch.context.subscribe()
1402    }
1403
1404    pub fn subscribe_attach(&self) -> watch::Receiver<usize> {
1405        self.watch.attach.subscribe()
1406    }
1407
1408    pub fn subscribe_pending_approvals(&self) -> watch::Receiver<Vec<PendingApproval>> {
1409        self.interactions.approval.subscribe()
1410    }
1411
1412    pub fn meta(&self) -> Option<crate::session_meta::SessionMeta> {
1413        crate::session_meta::SessionMeta::load(&self.dir)
1414    }
1415
1416    pub fn request_manual_compact(&self) {
1417        self.compaction
1418            .manual_pending
1419            .store(true, std::sync::atomic::Ordering::SeqCst);
1420    }
1421
1422    pub fn take_manual_compact_request(&self) -> bool {
1423        self.compaction
1424            .manual_pending
1425            .swap(false, std::sync::atomic::Ordering::SeqCst)
1426    }
1427
1428    pub fn set_goal(&self, goal: Option<String>) {
1429        let _ = self.watch.goal.send(goal);
1430    }
1431
1432    pub fn set_attach_count(&self, count: usize) {
1433        let _ = self.watch.attach.send(count);
1434    }
1435
1436    #[allow(clippy::too_many_arguments)]
1437    pub fn record_llm_call(
1438        &self,
1439        model: &str,
1440        tokens_in: u64,
1441        tokens_out: u64,
1442        cache_read: u64,
1443        cache_write: u64,
1444        ttft_ms: Option<u64>,
1445        tokens_per_sec: Option<f64>,
1446    ) {
1447        self.record_llm_usage(
1448            None,
1449            model,
1450            tokens_in,
1451            tokens_out,
1452            cache_read,
1453            cache_write,
1454            ttft_ms,
1455            tokens_per_sec,
1456            true,
1457        );
1458    }
1459
1460    #[allow(clippy::too_many_arguments)]
1461    pub fn record_context_plan_call(
1462        &self,
1463        provider: &str,
1464        model: &str,
1465        plan_id: crate::context_plan::ContextPlanId,
1466        call_purpose: crate::context_plan::ContextCallPurpose,
1467        call_identity: crate::context_plan::ContextCallIdentity,
1468        usage: &crate::provider::TokenUsage,
1469        ttft_ms: Option<u64>,
1470        tokens_per_sec: Option<f64>,
1471    ) {
1472        let key = crate::context_plan::ContextUsageKey {
1473            provider: provider.to_string(),
1474            model: model.to_string(),
1475            call_purpose,
1476            call_identity: call_identity.clone(),
1477        };
1478        let record = crate::context_plan::ContextUsageRecord {
1479            plan_id,
1480            usage: usage.clone(),
1481        };
1482        self.compaction
1483            .last_context_usage
1484            .lock()
1485            .expect("context usage lock poisoned")
1486            .insert(key, record);
1487
1488        let total_input = usage.prompt_input();
1489        self.watch.context.send_modify(|snap| {
1490            let bucket_idx = snap
1491                .usage_buckets
1492                .iter()
1493                .position(|bucket| {
1494                    bucket.provider == provider
1495                        && bucket.model == model
1496                        && bucket.call_purpose == call_purpose
1497                        && bucket.call_scope == call_identity.scope
1498                })
1499                .unwrap_or_else(|| {
1500                    snap.usage_buckets.push(ContextUsageBucket {
1501                        provider: provider.to_string(),
1502                        model: model.to_string(),
1503                        call_purpose,
1504                        call_scope: call_identity.scope,
1505                        ..Default::default()
1506                    });
1507                    snap.usage_buckets.len() - 1
1508                });
1509            let bucket = &mut snap.usage_buckets[bucket_idx];
1510            bucket.calls = bucket.calls.saturating_add(1);
1511            bucket.tokens_in = bucket.tokens_in.saturating_add(total_input);
1512            bucket.tokens_out = bucket.tokens_out.saturating_add(usage.output);
1513            bucket.cache_read = bucket.cache_read.saturating_add(usage.cached_input);
1514            bucket.cache_write = bucket.cache_write.saturating_add(usage.cache_write);
1515        });
1516
1517        let updates_model_window = matches!(
1518            (call_identity.scope, call_purpose),
1519            (
1520                crate::context_plan::ContextCallScope::Root,
1521                crate::context_plan::ContextCallPurpose::General
1522            )
1523        );
1524        self.record_llm_usage(
1525            Some(provider),
1526            model,
1527            total_input,
1528            usage.output,
1529            usage.cached_input,
1530            usage.cache_write,
1531            ttft_ms,
1532            tokens_per_sec,
1533            updates_model_window,
1534        );
1535    }
1536
1537    pub fn last_context_usage(
1538        &self,
1539        key: &crate::context_plan::ContextUsageKey,
1540    ) -> Option<crate::context_plan::ContextUsageRecord> {
1541        self.compaction
1542            .last_context_usage
1543            .lock()
1544            .expect("context usage lock poisoned")
1545            .get(key)
1546    }
1547
1548    pub(crate) fn observe_context_prefix(
1549        &self,
1550        provider: &str,
1551        model: &str,
1552        call_purpose: crate::context_plan::ContextCallPurpose,
1553        call_identity: crate::context_plan::ContextCallIdentity,
1554        snapshot: crate::context_plan::ContextPrefixSnapshot,
1555    ) -> crate::context_plan::ContextCacheObservation {
1556        self.compaction
1557            .last_context_prefix
1558            .lock()
1559            .expect("context prefix lock poisoned")
1560            .observe(call_purpose, call_identity, provider, model, snapshot)
1561    }
1562
1563    pub(crate) fn context_epoch(&self) -> Option<String> {
1564        self.compaction.context_epoch()
1565    }
1566
1567    #[allow(clippy::too_many_arguments)]
1568    fn record_llm_usage(
1569        &self,
1570        provider: Option<&str>,
1571        model: &str,
1572        tokens_in: u64,
1573        tokens_out: u64,
1574        cache_read: u64,
1575        cache_write: u64,
1576        ttft_ms: Option<u64>,
1577        tokens_per_sec: Option<f64>,
1578        updates_model_window: bool,
1579    ) {
1580        if updates_model_window && tokens_in > 0 {
1581            self.compaction
1582                .model_window_tokens
1583                .store(tokens_in, std::sync::atomic::Ordering::Relaxed);
1584        }
1585        self.watch.context.send_modify(|snap| {
1586            snap.tokens_in = snap.tokens_in.saturating_add(tokens_in);
1587            snap.tokens_out = snap.tokens_out.saturating_add(tokens_out);
1588            snap.cache_read = snap.cache_read.saturating_add(cache_read);
1589            snap.cache_write = snap.cache_write.saturating_add(cache_write);
1590            if updates_model_window {
1591                snap.model = model.to_string();
1592                if let Some(provider) = provider {
1593                    snap.provider = provider.to_string();
1594                }
1595                snap.last_ttft_ms = ttft_ms.unwrap_or(0);
1596                snap.last_tokens_per_sec = tokens_per_sec.unwrap_or(0.0);
1597            }
1598        });
1599        if updates_model_window {
1600            self.refresh_window_snapshot();
1601        }
1602    }
1603
1604    pub fn last_input_tokens(&self) -> u64 {
1605        self.compaction
1606            .model_window_tokens
1607            .load(std::sync::atomic::Ordering::Relaxed)
1608    }
1609
1610    pub async fn acquire_compact_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
1611        self.compaction.lock.lock().await
1612    }
1613
1614    pub async fn acquire_compact_lock_owned(&self) -> tokio::sync::OwnedMutexGuard<()> {
1615        self.compaction.lock.clone().lock_owned().await
1616    }
1617
1618    pub fn compact_lock_handle(&self) -> std::sync::Arc<tokio::sync::Mutex<()>> {
1619        self.compaction.lock.clone()
1620    }
1621
1622    pub fn refresh_window_snapshot(&self) {
1623        let provider_tokens = self.last_input_tokens();
1624        let estimated = crate::compaction::estimate_tokens_for_messages(&self.messages());
1625        let window = if provider_tokens > 0 {
1626            provider_tokens
1627        } else {
1628            estimated
1629        };
1630        let model = self.last_model();
1631        let budget = crate::model_registry::model_info(&model).context_budget;
1632        self.watch.context.send_modify(|snap| {
1633            snap.window_tokens = window;
1634            if budget > 0 {
1635                snap.window_budget = budget;
1636            }
1637        });
1638        let snap = self.watch.context.borrow();
1639        PersistedContextState {
1640            model,
1641            window_tokens: snap.window_tokens,
1642            window_budget: snap.window_budget,
1643        }
1644        .save(&self.dir);
1645    }
1646
1647    pub fn cumulative_input_tokens(&self) -> u64 {
1648        self.watch.context.borrow().tokens_in
1649    }
1650
1651    pub fn reset_input_tokens_to(&self, tokens: u64) {
1652        self.watch.context.send_modify(|snap| {
1653            snap.tokens_in = tokens;
1654        });
1655    }
1656
1657    pub fn last_model(&self) -> String {
1658        self.watch.context.borrow().model.clone()
1659    }
1660
1661    pub fn set_current_model(&self, model: impl Into<String>) {
1662        let model = model.into();
1663        let budget = crate::model_registry::model_info(&model).context_budget;
1664        self.watch.context.send_modify(|snap| {
1665            if snap.model != model {
1666                snap.provider.clear();
1667            }
1668            snap.model = model.clone();
1669            if budget > 0 {
1670                snap.window_budget = budget;
1671            }
1672        });
1673        let snap = self.watch.context.borrow();
1674        PersistedContextState {
1675            model,
1676            window_tokens: snap.window_tokens,
1677            window_budget: snap.window_budget,
1678        }
1679        .save(&self.dir);
1680    }
1681
1682    pub fn update_mcp_server(&self, status: crate::mcp::McpServerStatus) {
1683        self.watch.context.send_modify(|snap| {
1684            if let Some(existing) = snap.mcp_servers.iter_mut().find(|s| s.name == status.name) {
1685                *existing = status;
1686            } else {
1687                snap.mcp_servers.push(status);
1688            }
1689        });
1690    }
1691
1692    pub fn set_memory_recent_count(&self, count: u16) {
1693        self.watch.context.send_modify(|snap| {
1694            snap.memory_recent_count = count;
1695        });
1696    }
1697
1698    pub fn subscribe_todos(&self) -> watch::Receiver<Vec<crate::memory::todo::Todo>> {
1699        self.watch.todos.subscribe()
1700    }
1701
1702    pub fn todos_watch(&self) -> &watch::Sender<Vec<crate::memory::todo::Todo>> {
1703        &self.watch.todos
1704    }
1705
1706    pub fn subscribe_plans(&self) -> watch::Receiver<Vec<crate::memory::plan::Plan>> {
1707        self.watch.plans.subscribe()
1708    }
1709
1710    pub fn plans_watch(&self) -> &watch::Sender<Vec<crate::memory::plan::Plan>> {
1711        &self.watch.plans
1712    }
1713
1714    pub async fn refresh_plans_from_store_async(&self) {
1715        if self.dir.as_os_str().is_empty() {
1716            return;
1717        }
1718        let store = crate::memory::plan::PlanStore::at(&self.dir);
1719        match store.list().await {
1720            Ok(list) => {
1721                let _ = self.watch.plans.send(list);
1722            }
1723            Err(e) => {
1724                crate::notify!(
1725                    warn,
1726                    location = Log,
1727                    stack = dedupe("memory.refresh_plans_async", 60_000),
1728                    "refresh_plans_from_store_async: {e}"
1729                );
1730            }
1731        }
1732    }
1733
1734    pub fn refresh_todos_from_store(&self) {
1735        if self.dir.as_os_str().is_empty() {
1736            return;
1737        }
1738        let store = crate::memory::todo::TodoStore::at(&self.dir);
1739        match tokio::task::block_in_place(|| {
1740            tokio::runtime::Handle::try_current()
1741                .ok()
1742                .map(|h| h.block_on(store.list()))
1743        }) {
1744            Some(Ok(list)) => {
1745                let _ = self.watch.todos.send(list);
1746            }
1747            Some(Err(e)) => {
1748                crate::notify!(
1749                    warn,
1750                    location = Log,
1751                    stack = dedupe("memory.refresh_todos", 60_000),
1752                    "refresh_todos_from_store: {e}"
1753                );
1754            }
1755            None => {}
1756        }
1757    }
1758
1759    pub async fn refresh_todos_from_store_async(&self) {
1760        if self.dir.as_os_str().is_empty() {
1761            return;
1762        }
1763        let store = crate::memory::todo::TodoStore::at(&self.dir);
1764        match store.list().await {
1765            Ok(list) => {
1766                let _ = self.watch.todos.send(list);
1767            }
1768            Err(e) => {
1769                crate::notify!(
1770                    warn,
1771                    location = Log,
1772                    stack = dedupe("memory.refresh_todos_async", 60_000),
1773                    "refresh_todos_from_store_async: {e}"
1774                );
1775            }
1776        }
1777    }
1778
1779    pub fn sink(&self) -> &EventSink {
1780        &self.sink
1781    }
1782
1783    pub fn import_image_path(
1784        &self,
1785        path: impl AsRef<Path>,
1786    ) -> Result<crate::message::ImageSource, crate::error::RuntimeError> {
1787        crate::attachment_store::AttachmentStore::at(&self.dir).import_path(path)
1788    }
1789
1790    pub fn import_image_bytes(
1791        &self,
1792        bytes: &[u8],
1793        name: Option<&str>,
1794    ) -> Result<crate::message::ImageSource, crate::error::RuntimeError> {
1795        crate::attachment_store::AttachmentStore::at(&self.dir).import_bytes(bytes, name)
1796    }
1797
1798    pub fn import_image_base64(
1799        &self,
1800        data: &str,
1801        name: Option<&str>,
1802    ) -> Result<crate::message::ImageSource, crate::error::RuntimeError> {
1803        crate::attachment_store::AttachmentStore::at(&self.dir).import_base64(data, name)
1804    }
1805
1806    pub fn queue_image_bytes(
1807        &self,
1808        bytes: &[u8],
1809        name: Option<&str>,
1810    ) -> Result<usize, crate::error::RuntimeError> {
1811        let source = self.import_image_bytes(bytes, name)?;
1812        Ok(self.queue_image_source(source))
1813    }
1814
1815    pub fn queue_image_path(
1816        &self,
1817        path: impl AsRef<Path>,
1818    ) -> Result<usize, crate::error::RuntimeError> {
1819        let source = self.import_image_path(path)?;
1820        Ok(self.queue_image_source(source))
1821    }
1822
1823    pub fn queue_image_base64(
1824        &self,
1825        data: &str,
1826        name: Option<&str>,
1827    ) -> Result<usize, crate::error::RuntimeError> {
1828        let source = self.import_image_base64(data, name)?;
1829        Ok(self.queue_image_source(source))
1830    }
1831
1832    pub fn queue_image_source(&self, source: crate::message::ImageSource) -> usize {
1833        let mut pending = self.pending_images.lock().unwrap();
1834        pending.push(source);
1835        let count = pending.len();
1836        let _ = self.watch.attach.send(count);
1837        count
1838    }
1839
1840    pub fn pop_pending_image(&self) -> Option<crate::message::ImageSource> {
1841        let mut pending = self.pending_images.lock().unwrap();
1842        let removed = pending.pop();
1843        let _ = self.watch.attach.send(pending.len());
1844        removed
1845    }
1846
1847    pub fn remove_pending_image(&self, source: &crate::message::ImageSource) -> bool {
1848        let mut pending = self.pending_images.lock().unwrap();
1849        let Some(index) = pending.iter().position(|candidate| candidate == source) else {
1850            return false;
1851        };
1852        pending.remove(index);
1853        let _ = self.watch.attach.send(pending.len());
1854        true
1855    }
1856
1857    pub fn take_pending_images(&self) -> Vec<crate::message::ImageSource> {
1858        let images = std::mem::take(&mut *self.pending_images.lock().unwrap());
1859        let _ = self.watch.attach.send(0);
1860        images
1861    }
1862
1863    pub fn restore_pending_images(&self, mut images: Vec<crate::message::ImageSource>) -> usize {
1864        let mut pending = self.pending_images.lock().unwrap();
1865        images.append(&mut pending);
1866        *pending = images;
1867        let count = pending.len();
1868        let _ = self.watch.attach.send(count);
1869        count
1870    }
1871
1872    pub fn clear_pending_images(&self) {
1873        self.pending_images.lock().unwrap().clear();
1874        let _ = self.watch.attach.send(0);
1875    }
1876
1877    pub fn pending_image_names(&self) -> Vec<String> {
1878        self.pending_images
1879            .lock()
1880            .unwrap()
1881            .iter()
1882            .map(crate::attachment_store::display_name)
1883            .collect()
1884    }
1885
1886    pub fn pending_images(&self) -> Vec<crate::message::ImageSource> {
1887        self.pending_images.lock().unwrap().clone()
1888    }
1889
1890    pub fn pending_image_count(&self) -> usize {
1891        self.pending_images.lock().unwrap().len()
1892    }
1893
1894    /// Single-writer append. Emits the matching event before the in-memory push
1895    /// so events.jsonl remains the authority (§I5).
1896    pub fn append_message(&self, msg: Message, flow_run_id: Option<FlowRunId>) {
1897        AppendMessageCommand { msg, flow_run_id }.execute(self);
1898    }
1899
1900    pub fn append_context_records(
1901        &self,
1902        turn_id: TurnId,
1903        specs: impl IntoIterator<Item = crate::context_plan::ContextRecordSpec>,
1904    ) -> Vec<crate::context_plan::ContextRecord> {
1905        let mut messages = self.messages.lock().unwrap();
1906        let records = crate::context_plan::compile_context_records(&messages, specs);
1907        for record in &records {
1908            AppendMessageCommand {
1909                msg: Message::context_record(turn_id.clone(), record.clone()),
1910                flow_run_id: None,
1911            }
1912            .execute_with_messages(self, &mut messages);
1913        }
1914        records
1915    }
1916
1917    pub fn emit_attachment_degrade(
1918        &self,
1919        message_seq: u64,
1920        part_index: usize,
1921        file_basename: String,
1922        reason: String,
1923    ) {
1924        self.sink.emit(Event::AttachmentDegraded {
1925            turn_id: None,
1926            flow_run_id: None,
1927            message_seq,
1928            part_index,
1929            file_basename,
1930            reason,
1931        });
1932    }
1933
1934    pub fn record_attachment_degrade(&self, reason: &str) -> usize {
1935        let target = self.last_image_user_msg.lock().unwrap().take();
1936        let Some(entry) = target else {
1937            return 0;
1938        };
1939        let turn_id = self.turn.current_turn.lock().unwrap().clone();
1940        for (part_index, basename) in &entry.images {
1941            self.sink.emit(Event::AttachmentDegraded {
1942                turn_id: turn_id.clone(),
1943                flow_run_id: None,
1944                message_seq: entry.message_seq,
1945                part_index: *part_index,
1946                file_basename: basename.clone(),
1947                reason: reason.into(),
1948            });
1949        }
1950        if let Ok(mut messages) = self.messages.lock()
1951            && let Some(message) = messages.iter_mut().find(|message| {
1952                message.role == MessageRole::User && message.turn_id == entry.message_turn_id
1953            })
1954        {
1955            for (part_index, basename) in &entry.images {
1956                if let Some(part) = message.parts.get_mut(*part_index)
1957                    && matches!(part, crate::message::MessagePart::Image { .. })
1958                {
1959                    *part = crate::message::MessagePart::Text {
1960                        text: format!("[attachment unavailable: {basename} — {reason}]"),
1961                    };
1962                }
1963            }
1964        }
1965        entry.images.len()
1966    }
1967
1968    pub fn messages(&self) -> crate::message_stream::MessageWindow {
1969        self.message_stream.window()
1970    }
1971
1972    pub fn messages_full(&self) -> std::sync::Arc<Vec<Message>> {
1973        self.message_stream.full_messages()
1974    }
1975
1976    pub fn messages_handle(&self) -> std::sync::Arc<std::sync::Mutex<Vec<Message>>> {
1977        self.messages.clone()
1978    }
1979
1980    pub fn message_count(&self) -> usize {
1981        self.messages().len()
1982    }
1983
1984    pub fn user_message_count(&self) -> usize {
1985        self.messages()
1986            .iter()
1987            .filter(|m| matches!(m.role, MessageRole::User))
1988            .count()
1989    }
1990
1991    pub fn push_system_note(&self, text: String) {
1992        let _ = self
1993            .watch
1994            .stream_tx
1995            .send(crate::stream::StreamFrame::Note(text));
1996    }
1997
1998    pub fn approval_cooldown_ok_for_compact(&self) -> bool {
1999        self.sink.last_compact_ago_seconds().is_none_or(|s| s >= 60)
2000    }
2001
2002    pub fn emit_compact_warning(
2003        &self,
2004        model: &str,
2005        current_tokens: u64,
2006        threshold: u64,
2007        budget: u64,
2008        reason: &str,
2009    ) {
2010        let message = format!(
2011            "context {current_tokens} > threshold {threshold} (budget {budget}, model {model}); skipping compaction: {reason}"
2012        );
2013        self.sink.emit(Event::WatchWarn {
2014            turn_id: self.turn.current_turn.lock().unwrap().clone(),
2015            flow_run_id: None,
2016            target: "context.compaction".into(),
2017            trigger: "auto_compact".into(),
2018            message,
2019        });
2020        self.push_system_note(format!("[warn] compaction skipped: {reason}"));
2021    }
2022
2023    /// Convenience wrapper that computes the compact range and token count
2024    /// from the current message window. Used by tests and internal callers
2025    /// that don't already have a pre-computed range.
2026    pub fn compact_messages_auto(&self, summary: String) -> Option<CompactResult> {
2027        let msgs = self.messages();
2028        let tokens = crate::compaction::estimate_tokens_for_messages(&msgs);
2029        let info = crate::model_registry::model_info(&self.last_model());
2030        let target = info.compaction_target_after();
2031        let range = crate::compaction::find_compact_range(&msgs, target)?;
2032        self.compact_messages(summary, range, tokens)
2033    }
2034
2035    pub fn commit_rewritten_window(
2036        &self,
2037        replacement: Vec<Message>,
2038        before_tokens: u64,
2039        before_window_tokens: u64,
2040        rewritten_count: usize,
2041    ) -> Option<CompactResult> {
2042        let after_tokens = crate::compaction::estimate_tokens_for_messages(&replacement);
2043        if rewritten_count == 0 || after_tokens >= before_window_tokens {
2044            return None;
2045        }
2046        let summary = format!(
2047            "[atman: persistently compacted output from {rewritten_count} retained messages]"
2048        );
2049        self.sink.mark_compacted();
2050        self.sink.emit(Event::ContextCompact {
2051            session_id: self.id.to_string(),
2052            flow_run_id: None,
2053            before_tokens,
2054            after_tokens,
2055            compacted_range_start: 0,
2056            compacted_range_end: 0,
2057            summary_text: Some(summary.clone()),
2058            replacement_msg_seq: None,
2059        });
2060        self.sink.emit(Event::CompactionSummary {
2061            session_id: self.id.to_string(),
2062            flow_run_id: None,
2063            range_start: 0,
2064            range_end: 0,
2065            compacted_count: rewritten_count,
2066            before_tokens,
2067            after_tokens,
2068            summary: summary.clone(),
2069        });
2070        let _ = self
2071            .watch
2072            .stream_tx
2073            .send(crate::stream::StreamFrame::CompactionSummary {
2074                phase: crate::stream::CompactionPhase::Finished,
2075                range_start: 0,
2076                range_end: 0,
2077                summary,
2078                before_tokens,
2079                after_tokens,
2080                compacted_count: rewritten_count,
2081            });
2082        self.compaction
2083            .model_window_tokens
2084            .store(after_tokens, std::sync::atomic::Ordering::Relaxed);
2085        if let Ok(mut messages) = self.messages.lock() {
2086            *messages = replacement.clone();
2087        }
2088        self.compaction.update_context_epoch(&replacement);
2089        self.sink.emit(Event::Checkpoint {
2090            session_id: self.id.to_string(),
2091            flow_run_id: None,
2092            messages: replacement,
2093            window_tokens: after_tokens,
2094        });
2095        self.refresh_window_snapshot();
2096        Some(CompactResult {
2097            before_tokens,
2098            after_tokens,
2099            compacted_start: 0,
2100            compacted_end: 0,
2101        })
2102    }
2103
2104    pub fn commit_compacted_window(
2105        &self,
2106        summary: String,
2107        replacement: Vec<Message>,
2108        range: crate::compaction::CompactRange,
2109        before_tokens: u64,
2110        before_window_tokens: u64,
2111    ) -> Option<CompactResult> {
2112        let after_tokens = crate::compaction::estimate_tokens_for_messages(&replacement);
2113        if after_tokens >= before_window_tokens {
2114            self.push_system_note(format!(
2115                "compaction skipped: replacement would not shrink transcript ({} >= {} tokens)",
2116                after_tokens, before_window_tokens
2117            ));
2118            return None;
2119        }
2120        self.sink.mark_compacted();
2121        self.sink.emit(Event::ContextCompact {
2122            session_id: self.id.to_string(),
2123            flow_run_id: None,
2124            before_tokens,
2125            after_tokens,
2126            compacted_range_start: range.start as u64,
2127            compacted_range_end: range.end.saturating_sub(1) as u64,
2128            summary_text: Some(summary.clone()),
2129            replacement_msg_seq: None,
2130        });
2131        self.sink.emit(Event::CompactionSummary {
2132            session_id: self.id.to_string(),
2133            flow_run_id: None,
2134            range_start: range.start as u64,
2135            range_end: range.end.saturating_sub(1) as u64,
2136            compacted_count: range.end - range.start,
2137            before_tokens,
2138            after_tokens,
2139            summary: summary.clone(),
2140        });
2141        let _ = self
2142            .watch
2143            .stream_tx
2144            .send(crate::stream::StreamFrame::CompactionSummary {
2145                phase: crate::stream::CompactionPhase::Finished,
2146                range_start: range.start,
2147                range_end: range.end.saturating_sub(1),
2148                summary,
2149                before_tokens,
2150                after_tokens,
2151                compacted_count: range.end - range.start,
2152            });
2153        self.compaction
2154            .model_window_tokens
2155            .store(after_tokens, std::sync::atomic::Ordering::Relaxed);
2156        if let Ok(mut messages) = self.messages.lock() {
2157            *messages = replacement.clone();
2158        }
2159        self.compaction.update_context_epoch(&replacement);
2160        self.sink.emit(Event::Checkpoint {
2161            session_id: self.id.to_string(),
2162            flow_run_id: None,
2163            messages: replacement,
2164            window_tokens: after_tokens,
2165        });
2166        self.refresh_window_snapshot();
2167        Some(CompactResult {
2168            before_tokens,
2169            after_tokens,
2170            compacted_start: range.start,
2171            compacted_end: range.end,
2172        })
2173    }
2174
2175    pub fn compact_messages(
2176        &self,
2177        summary: String,
2178        range: crate::compaction::CompactRange,
2179        before_tokens: u64,
2180    ) -> Option<CompactResult> {
2181        use crate::compaction::{estimate_tokens_for_messages, replace_range_with_summary};
2182        let msgs = self.messages();
2183        let turn_id = msgs
2184            .get(range.start)
2185            .map(|m| m.turn_id.clone())
2186            .unwrap_or_else(TurnId::now);
2187        let after = replace_range_with_summary(&msgs, &range, summary.clone(), turn_id.clone());
2188        let after_tokens = estimate_tokens_for_messages(&after);
2189        if after_tokens >= before_tokens {
2190            self.push_system_note(format!(
2191                "compaction skipped: summary would not shrink transcript ({} >= {} tokens)",
2192                after_tokens, before_tokens
2193            ));
2194            return None;
2195        }
2196        let replacement_msg = after.first().cloned().unwrap_or_else(|| {
2197            Message::system_compact_summary(
2198                turn_id.clone(),
2199                summary.clone(),
2200                range.start as u64,
2201                range.end.saturating_sub(1) as u64,
2202                range.end - range.start,
2203            )
2204        });
2205        self.sink.mark_compacted();
2206        let replacement_seq = self.sink.next_seq_peek();
2207        self.sink.emit(Event::SystemMsg {
2208            turn_id: turn_id.clone(),
2209            flow_run_id: None,
2210            message: replacement_msg,
2211        });
2212        self.sink.emit(Event::ContextCompact {
2213            session_id: self.id.to_string(),
2214            flow_run_id: None,
2215            before_tokens,
2216            after_tokens,
2217            compacted_range_start: range.start as u64,
2218            compacted_range_end: range.end.saturating_sub(1) as u64,
2219            summary_text: Some(summary.clone()),
2220            replacement_msg_seq: Some(replacement_seq),
2221        });
2222        self.sink.emit(Event::CompactionSummary {
2223            session_id: self.id.to_string(),
2224            flow_run_id: None,
2225            range_start: range.start as u64,
2226            range_end: range.end.saturating_sub(1) as u64,
2227            compacted_count: range.end - range.start,
2228            before_tokens,
2229            after_tokens,
2230            summary: summary.clone(),
2231        });
2232        let _ = self
2233            .watch
2234            .stream_tx
2235            .send(crate::stream::StreamFrame::CompactionSummary {
2236                phase: crate::stream::CompactionPhase::Finished,
2237                range_start: range.start,
2238                range_end: range.end.saturating_sub(1),
2239                summary,
2240                before_tokens,
2241                after_tokens,
2242                compacted_count: range.end - range.start,
2243            });
2244        let checkpoint_messages = self.messages();
2245        let window_tokens = estimate_tokens_for_messages(&checkpoint_messages);
2246        self.compaction
2247            .model_window_tokens
2248            .store(window_tokens, std::sync::atomic::Ordering::Relaxed);
2249        self.compaction
2250            .update_context_epoch(checkpoint_messages.as_ref());
2251        // Sync the messages Vec (root's messages_handle) with the compacted
2252        // windowed view so root's llm context via messages_handle respects
2253        // compaction (branch2 retired → unified on messages_handle).
2254        let window_owned = checkpoint_messages.to_vec();
2255        if let Ok(mut vec) = self.messages.lock() {
2256            *vec = window_owned;
2257        }
2258        self.refresh_window_snapshot();
2259        self.sink.emit(Event::Checkpoint {
2260            session_id: self.id.to_string(),
2261            flow_run_id: None,
2262            messages: checkpoint_messages.to_vec(),
2263            window_tokens,
2264        });
2265        Some(CompactResult {
2266            before_tokens,
2267            after_tokens,
2268            compacted_start: range.start,
2269            compacted_end: range.end,
2270        })
2271    }
2272
2273    pub fn begin_turn(&self, user_msg: Message) -> TurnId {
2274        BeginTurnCommand { user_msg }.execute(self)
2275    }
2276
2277    pub fn mark_streamed(&self) {
2278        self.turn
2279            .streamed
2280            .store(true, std::sync::atomic::Ordering::Relaxed);
2281    }
2282
2283    pub fn take_streamed_flag(&self) -> bool {
2284        self.turn
2285            .streamed
2286            .swap(false, std::sync::atomic::Ordering::Relaxed)
2287    }
2288
2289    pub fn end_turn(&self) {
2290        self.turn
2291            .streamed
2292            .store(false, std::sync::atomic::Ordering::Relaxed);
2293        let turn_id = self.turn.current_turn.lock().unwrap().take();
2294        if let Some(turn_id) = turn_id {
2295            let mut q = self.injection_queue.lock().unwrap();
2296            for inj in q.iter_mut() {
2297                if inj.state == InjectionState::Pending && inj.turn_id == turn_id {
2298                    inj.state = InjectionState::Cancelled;
2299                    let _ = self.injection_tx.send(inj.clone());
2300                }
2301            }
2302            drop(q);
2303            self.sink.emit(Event::TurnEnd { turn_id });
2304        }
2305    }
2306
2307    pub fn current_turn(&self) -> Option<TurnId> {
2308        self.turn.current_turn.lock().unwrap().clone()
2309    }
2310
2311    pub fn enqueue_injection(&self, text: impl Into<String>) -> Result<InjectionId, EnqueueError> {
2312        self.enqueue_injection_with_level(text, crate::injection::InjectionLevel::L1Nudge, None)
2313    }
2314
2315    pub fn enqueue_injection_with_level(
2316        &self,
2317        text: impl Into<String>,
2318        level: crate::injection::InjectionLevel,
2319        redirect_target: Option<String>,
2320    ) -> Result<InjectionId, EnqueueError> {
2321        let turn_id = self
2322            .turn
2323            .current_turn
2324            .lock()
2325            .unwrap()
2326            .clone()
2327            .ok_or(EnqueueError::NoActiveTurn)?;
2328        let inj = Injection::with_level(turn_id.clone(), text, level, redirect_target);
2329        let id = inj.id.clone();
2330        self.sink.emit(Event::UserInject {
2331            turn_id,
2332            injection: inj.clone(),
2333        });
2334        self.injection_queue.lock().unwrap().push(inj.clone());
2335        let _ = self.injection_tx.send(inj);
2336        Ok(id)
2337    }
2338
2339    pub fn subscribe_injections(&self) -> broadcast::Receiver<Injection> {
2340        self.injection_tx.subscribe()
2341    }
2342
2343    pub fn mark_injection_consumed(&self, id: &InjectionId) {
2344        let mut q = self.injection_queue.lock().unwrap();
2345        for inj in q.iter_mut() {
2346            if inj.id == *id && inj.state == InjectionState::Pending {
2347                inj.state = InjectionState::Injected;
2348                let _ = self.injection_tx.send(inj.clone());
2349                return;
2350            }
2351        }
2352    }
2353
2354    pub fn peek_pending_l2_or_higher(&self, turn_id: &TurnId) -> Option<Injection> {
2355        let q = self.injection_queue.lock().unwrap();
2356        q.iter()
2357            .find(|i| {
2358                i.state == InjectionState::Pending
2359                    && i.turn_id == *turn_id
2360                    && !matches!(i.level, crate::injection::InjectionLevel::L1Nudge)
2361            })
2362            .cloned()
2363    }
2364
2365    /// Drain all Pending injections for `turn_id`. Marks them Injected.
2366    /// Returns them in creation order.
2367    pub fn drain_injections(&self, turn_id: &TurnId) -> Vec<Injection> {
2368        let mut q = self.injection_queue.lock().unwrap();
2369        let mut out = Vec::new();
2370        for inj in q.iter_mut() {
2371            if inj.state == InjectionState::Pending && inj.turn_id == *turn_id {
2372                inj.state = InjectionState::Injected;
2373                let _ = self.injection_tx.send(inj.clone());
2374                out.push(inj.clone());
2375            }
2376        }
2377        out
2378    }
2379
2380    pub fn list_pending_injections(&self) -> Vec<Injection> {
2381        self.injection_queue
2382            .lock()
2383            .unwrap()
2384            .iter()
2385            .filter(|i| i.state == InjectionState::Pending)
2386            .cloned()
2387            .collect()
2388    }
2389
2390    pub fn cancel_flow(&self) {
2391        self.turn.flow_cancel.lock().unwrap().cancel();
2392    }
2393
2394    pub fn flow_cancel_token(&self) -> CancellationToken {
2395        self.turn.flow_cancel.lock().unwrap().clone()
2396    }
2397
2398    pub async fn shutdown(&self) {
2399        let writer = self.writer.lock().unwrap().take();
2400        if let Some(writer) = writer {
2401            writer.shutdown().await;
2402        }
2403    }
2404
2405    // Rides FIFO queue ordering: once flush's own barrier is written,
2406    // every earlier sink.emit is on disk too.
2407    #[allow(clippy::await_holding_lock)]
2408    pub async fn flush_writer(&self) {
2409        let guard = self.writer.lock().unwrap();
2410        let Some(ref writer) = *guard else {
2411            return;
2412        };
2413        writer.flush().await;
2414    }
2415}
2416
2417#[derive(Debug, thiserror::Error)]
2418pub enum EnqueueError {
2419    #[error("enqueue_injection called with no active turn")]
2420    NoActiveTurn,
2421}
2422
2423pub struct AppendMessageCommand {
2424    pub msg: Message,
2425    pub flow_run_id: Option<FlowRunId>,
2426}
2427
2428impl AppendMessageCommand {
2429    pub fn execute(&self, session: &Session) -> u64 {
2430        let mut messages = session.messages.lock().unwrap();
2431        self.execute_with_messages(session, &mut messages)
2432    }
2433
2434    fn execute_with_messages(&self, session: &Session, messages: &mut Vec<Message>) -> u64 {
2435        let flow_run_id_str = self.flow_run_id.as_ref().map(|r| r.0.to_string());
2436        let msg = crate::tools::tool_output::maybe_truncate_tool_message_with_budget(
2437            &self.msg,
2438            Some(&session.output_store),
2439            session.tool_output_budget(),
2440        );
2441        let is_internal = msg.origin == crate::message::MessageOrigin::Internal;
2442        let event =
2443            match msg.role {
2444                MessageRole::User => Event::UserMsg {
2445                    turn_id: msg.turn_id.clone(),
2446                    flow_run_id: self.flow_run_id.clone(),
2447                    message: msg.clone(),
2448                },
2449                MessageRole::Assistant => {
2450                    if !is_internal {
2451                        let _ = session.watch.stream_tx.send(
2452                            crate::stream::StreamFrame::AssistantMsg {
2453                                flow_run_id: flow_run_id_str.clone(),
2454                                message: msg.clone(),
2455                            },
2456                        );
2457                        for source in extract_mermaid_blocks(&msg) {
2458                            let _ = session.watch.stream_tx.send(
2459                                crate::stream::StreamFrame::MermaidDiagram {
2460                                    source: source.clone(),
2461                                },
2462                            );
2463                            session
2464                                .sink
2465                                .emit(crate::event::Event::MermaidDiagram { source });
2466                        }
2467                    }
2468                    Event::AssistantMsg {
2469                        turn_id: msg.turn_id.clone(),
2470                        flow_run_id: self.flow_run_id.clone(),
2471                        message: msg.clone(),
2472                    }
2473                }
2474                MessageRole::Tool => {
2475                    if !is_internal {
2476                        let _ = session.watch.stream_tx.send(
2477                            crate::stream::StreamFrame::ToolResultMsg {
2478                                flow_run_id: flow_run_id_str.clone(),
2479                                message: msg.clone(),
2480                            },
2481                        );
2482                    }
2483                    Event::ToolResultMsg {
2484                        turn_id: msg.turn_id.clone(),
2485                        flow_run_id: self.flow_run_id.clone(),
2486                        message: msg.clone(),
2487                    }
2488                }
2489                MessageRole::System => Event::SystemMsg {
2490                    turn_id: msg.turn_id.clone(),
2491                    flow_run_id: self.flow_run_id.clone(),
2492                    message: msg.clone(),
2493                },
2494            };
2495        let seq = session.sink.emit_returning_seq(event);
2496        if matches!(msg.role, MessageRole::User) {
2497            let images: Vec<(usize, String)> = msg
2498                .parts
2499                .iter()
2500                .enumerate()
2501                .filter_map(|(i, p)| match p {
2502                    crate::message::MessagePart::Image { source } => {
2503                        let basename = match &source.data {
2504                            crate::message::ImageData::Path { path } => path
2505                                .file_name()
2506                                .and_then(|n| n.to_str())
2507                                .unwrap_or("unknown")
2508                                .to_string(),
2509                            crate::message::ImageData::Base64 { .. } => "base64".into(),
2510                            crate::message::ImageData::Artifact { .. } => {
2511                                crate::attachment_store::display_name(source)
2512                            }
2513                        };
2514                        Some((i, basename))
2515                    }
2516                    _ => None,
2517                })
2518                .collect();
2519            if !images.is_empty() {
2520                *session.last_image_user_msg.lock().unwrap() = Some(LastImageUserMsg {
2521                    message_seq: seq,
2522                    message_turn_id: msg.turn_id.clone(),
2523                    images,
2524                });
2525            }
2526        }
2527        messages.push(msg.clone());
2528        seq
2529    }
2530}
2531
2532pub struct BeginTurnCommand {
2533    pub user_msg: Message,
2534}
2535
2536impl BeginTurnCommand {
2537    pub fn execute(&self, session: &Session) -> TurnId {
2538        let turn_id = self.user_msg.turn_id.clone();
2539        *session.turn.current_turn.lock().unwrap() = Some(turn_id.clone());
2540        *session.turn.flow_cancel.lock().unwrap() = tokio_util::sync::CancellationToken::new();
2541        session.sink.emit(Event::TurnStart {
2542            turn_id: turn_id.clone(),
2543        });
2544        AppendMessageCommand {
2545            msg: self.user_msg.clone(),
2546            flow_run_id: None,
2547        }
2548        .execute(session);
2549        turn_id
2550    }
2551}
2552
2553fn extract_mermaid_blocks(msg: &crate::message::Message) -> Vec<String> {
2554    let text = msg.text_concat();
2555    let mut blocks = Vec::new();
2556    let mut lines = text.lines().peekable();
2557    while let Some(line) = lines.next() {
2558        let trimmed = line.trim();
2559        if trimmed.starts_with("```") {
2560            let lang = trimmed.trim_start_matches("```").trim();
2561            if lang == "mermaid" {
2562                let mut source = String::new();
2563                for inner in lines.by_ref() {
2564                    if inner.trim() == "```" {
2565                        break;
2566                    }
2567                    if !source.is_empty() {
2568                        source.push('\n');
2569                    }
2570                    source.push_str(inner);
2571                }
2572                if !source.is_empty() {
2573                    blocks.push(source);
2574                }
2575            } else {
2576                for inner in lines.by_ref() {
2577                    if inner.trim() == "```" {
2578                        break;
2579                    }
2580                }
2581            }
2582        }
2583    }
2584    blocks
2585}
2586
2587#[cfg(test)]
2588mod tests {
2589    use super::*;
2590    use std::collections::BTreeSet;
2591    use tempfile::TempDir;
2592
2593    #[test]
2594    fn last_context_usage_store_evicts_the_oldest_identity() {
2595        let mut store = LastContextUsageStore::default();
2596        for index in 0..=MAX_LAST_CONTEXT_USAGES {
2597            store.insert(
2598                crate::context_plan::ContextUsageKey {
2599                    provider: "provider".into(),
2600                    model: format!("model-{index}"),
2601                    call_purpose: crate::context_plan::ContextCallPurpose::General,
2602                    call_identity: crate::context_plan::ContextCallIdentity::detached(),
2603                },
2604                crate::context_plan::ContextUsageRecord {
2605                    plan_id: crate::context_plan::ContextPlanId::now(),
2606                    usage: crate::provider::TokenUsage::default(),
2607                },
2608            );
2609        }
2610
2611        assert_eq!(store.entries.len(), MAX_LAST_CONTEXT_USAGES);
2612        assert!(!store.entries.keys().any(|key| key.model == "model-0"));
2613        assert!(
2614            store
2615                .entries
2616                .keys()
2617                .any(|key| key.model == format!("model-{MAX_LAST_CONTEXT_USAGES}"))
2618        );
2619    }
2620
2621    fn permission_authority() -> crate::flow_authority::EffectiveAuthority {
2622        use crate::trust::{ExecutionPolicy, PolicyAction, RiskKind};
2623        crate::flow_authority::EffectiveAuthority {
2624            execution_policy: ExecutionPolicy::Controlled,
2625            allowed_tiers: [true; 5],
2626            allowed_risks: BTreeSet::from([
2627                RiskKind::Network,
2628                RiskKind::WorkspaceExternal,
2629                RiskKind::Irreversible,
2630                RiskKind::FilesystemWrite,
2631                RiskKind::ProcessSpawn,
2632                RiskKind::RepositoryMutation,
2633            ]),
2634            tier_ceiling: [PolicyAction::Auto; 5],
2635            risk_ceiling: [PolicyAction::Auto; 6],
2636            shell: true,
2637            permission_management: true,
2638            workspace_root: None,
2639        }
2640    }
2641
2642    fn submit_session_permission(session: &Session) -> crate::permission::PermissionRequestId {
2643        let identity = session
2644            .flow_registry
2645            .register_root(
2646                session.id().to_string(),
2647                crate::event::FlowRunId::now(),
2648                permission_authority(),
2649            )
2650            .unwrap();
2651        let intent = crate::permission::PermissionIntent {
2652            tool_use_id: "session-constructor-call".into(),
2653            tool_name: "bash.spawn".into(),
2654            call_intent: None,
2655            tier: crate::tool::Tier::Two,
2656            risks: BTreeSet::new(),
2657            args_digest: "sha256:session-constructor".into(),
2658            preview: None,
2659            provenance: crate::permission::ResourceProvenance::none(),
2660        };
2661        let policy = crate::trust::TrustConfig {
2662            mode: crate::trust::TrustMode::Steady,
2663            ..crate::trust::TrustConfig::default()
2664        };
2665        let crate::permission::SubmissionOutcome::Pending(pending) = session
2666            .permission_broker
2667            .submit(
2668                Some(&identity.session_id),
2669                Some(&identity.run_id),
2670                intent,
2671                false,
2672                &policy,
2673            )
2674            .unwrap()
2675        else {
2676            panic!("expected pending session permission");
2677        };
2678        pending.request.request_id.clone()
2679    }
2680
2681    fn write_events(dir: &Path, lines: &[&str]) {
2682        let path = dir.join("events.jsonl");
2683        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
2684    }
2685
2686    #[tokio::test]
2687    async fn resumed_session_parses_each_event_once_and_hands_off_transcript() {
2688        let root = TempDir::new().unwrap();
2689        let created = Session::open(root.path()).unwrap();
2690        let sid = created.id().to_string();
2691        let turn_id = crate::event::TurnId::now();
2692        created.sink().emit(crate::event::Event::UserMsg {
2693            turn_id: turn_id.clone(),
2694            flow_run_id: None,
2695            message: crate::message::Message::user_text(turn_id, "once"),
2696        });
2697        created.flush_writer().await;
2698        created.shutdown().await;
2699        drop(created);
2700        crate::event_log::reader::reset_parse_attempts();
2701        let mut transcript = Vec::new();
2702        let mut observer = |entry| transcript.push(entry);
2703
2704        let reopened = Session::open_existing_with_replay_observer(
2705            root.path(),
2706            &sid,
2707            None,
2708            None,
2709            crate::trust::TrustConfig::default(),
2710            &mut observer,
2711        )
2712        .unwrap();
2713
2714        assert_eq!(crate::event_log::reader::parse_attempts(), 1);
2715        assert!(matches!(
2716            transcript.as_slice(),
2717            [TranscriptEntry::Message { message, .. }] if message.text_concat() == "once"
2718        ));
2719        assert_eq!(reopened.messages_full().len(), 1);
2720        let turn_id = crate::event::TurnId::now();
2721        reopened.sink().emit(crate::event::Event::AssistantMsg {
2722            turn_id: turn_id.clone(),
2723            flow_run_id: None,
2724            message: crate::message::Message::assistant_text(turn_id, "after open"),
2725        });
2726        let suffix = reopened.transcript_since_open();
2727        assert!(matches!(
2728            suffix.as_slice(),
2729            [TranscriptEntry::Message { message, .. }] if message.text_concat() == "after open"
2730        ));
2731        reopened.shutdown().await;
2732    }
2733
2734    #[test]
2735    #[ignore = "large synthetic verification for the streaming resume path"]
2736    fn resume_parses_five_hundred_thousand_lines_once() {
2737        use std::io::Write;
2738
2739        const EVENT_COUNT: usize = 500_000;
2740        let dir = TempDir::new().unwrap();
2741        let path = dir.path().join("events.jsonl");
2742        let envelope = crate::event::EventEnvelope::new(
2743            1,
2744            crate::event::Event::TurnStart {
2745                turn_id: crate::event::TurnId::now(),
2746            },
2747        );
2748        let mut line = serde_json::to_vec(&envelope).unwrap();
2749        line.push(b'\n');
2750        let file = std::fs::File::create(&path).unwrap();
2751        let mut writer = std::io::BufWriter::new(file);
2752        for _ in 0..EVENT_COUNT {
2753            writer.write_all(&line).unwrap();
2754        }
2755        writer.flush().unwrap();
2756        let file_bytes = std::fs::metadata(&path).unwrap().len();
2757
2758        crate::event_log::reader::reset_parse_attempts();
2759        let started = std::time::Instant::now();
2760        let mut transcript = Vec::new();
2761        let mut observer = |entry| transcript.push(entry);
2762        let _ =
2763            crate::event_log::replay::SessionReplay::from_path(&path, Some(&mut observer)).unwrap();
2764        let elapsed = started.elapsed();
2765        let attempts = crate::event_log::reader::parse_attempts();
2766        assert_eq!(attempts, EVENT_COUNT as u64);
2767        eprintln!(
2768            "baseline resume: events={EVENT_COUNT} file_bytes={file_bytes} parses={attempts} elapsed_ms={}",
2769            elapsed.as_millis()
2770        );
2771    }
2772
2773    #[test]
2774    fn new_session_constructor_persists_supplied_trust() {
2775        let root = TempDir::new().unwrap();
2776        let trust = crate::trust::TrustConfig {
2777            mode: crate::trust::TrustMode::Eager,
2778            ..crate::trust::TrustConfig::default()
2779        };
2780        let session = Session::open_with_trust(root.path(), trust.clone()).unwrap();
2781        assert_eq!(session.trust_config(), trust);
2782        assert_eq!(read_trust(session.dir()).unwrap(), trust);
2783    }
2784
2785    #[test]
2786    fn existing_session_constructor_preserves_session_trust_over_global() {
2787        let root = TempDir::new().unwrap();
2788        let session_trust = crate::trust::TrustConfig {
2789            mode: crate::trust::TrustMode::Eager,
2790            ..crate::trust::TrustConfig::default()
2791        };
2792        let created = Session::open_with_trust(root.path(), session_trust.clone()).unwrap();
2793        let sid = created.id().to_string();
2794        drop(created);
2795        let global_trust = crate::trust::TrustConfig {
2796            mode: crate::trust::TrustMode::Reckless,
2797            ..crate::trust::TrustConfig::default()
2798        };
2799        let reopened = Session::open_existing_with_trust(root.path(), &sid, global_trust).unwrap();
2800        assert_eq!(reopened.trust_config(), session_trust);
2801    }
2802
2803    #[test]
2804    fn missing_trust_uses_global_and_persists_it() {
2805        let root = TempDir::new().unwrap();
2806        let created = Session::open(root.path()).unwrap();
2807        let sid = created.id().to_string();
2808        let session_dir = created.dir().to_path_buf();
2809        drop(created);
2810        std::fs::remove_file(trust_path(&session_dir)).unwrap();
2811        let global_trust = crate::trust::TrustConfig {
2812            mode: crate::trust::TrustMode::Reckless,
2813            ..crate::trust::TrustConfig::default()
2814        };
2815        let reopened =
2816            Session::open_existing_with_trust(root.path(), &sid, global_trust.clone()).unwrap();
2817        assert_eq!(reopened.trust_config(), global_trust);
2818        assert_eq!(read_trust(&session_dir).unwrap(), global_trust);
2819    }
2820
2821    #[test]
2822    fn corrupt_trust_rejects_existing_session() {
2823        let root = TempDir::new().unwrap();
2824        let created = Session::open(root.path()).unwrap();
2825        let sid = created.id().to_string();
2826        let session_dir = created.dir().to_path_buf();
2827        drop(created);
2828        std::fs::write(trust_path(&session_dir), b"not json").unwrap();
2829        let error = match Session::open_existing_with_trust(
2830            root.path(),
2831            &sid,
2832            crate::trust::TrustConfig::default(),
2833        ) {
2834            Ok(_) => panic!("corrupt trust snapshot was accepted"),
2835            Err(error) => error,
2836        };
2837        assert!(matches!(error, SessionOpenError::Trust { .. }));
2838    }
2839
2840    #[test]
2841    fn open_existing_rejects_obsolete_outside_in_trust_snapshot() {
2842        let root = TempDir::new().unwrap();
2843        let created = Session::open(root.path()).unwrap();
2844        let sid = created.id().to_string();
2845        let session_dir = created.dir().to_path_buf();
2846        drop(created);
2847        std::fs::write(
2848            trust_path(&session_dir),
2849            br#"{"mode":"steady","outside":"allow"}"#,
2850        )
2851        .unwrap();
2852
2853        let error = match Session::open_existing_with_trust(
2854            root.path(),
2855            &sid,
2856            crate::trust::TrustConfig::default(),
2857        ) {
2858            Ok(_) => panic!("obsolete outside field was accepted"),
2859            Err(error) => error,
2860        };
2861
2862        assert!(
2863            matches!(error, SessionOpenError::Trust { source, .. } if source.to_string().contains("outside"))
2864        );
2865    }
2866
2867    #[test]
2868    fn open_existing_rejects_obsolete_nested_risk_in_trust_snapshot() {
2869        let root = TempDir::new().unwrap();
2870        let created = Session::open(root.path()).unwrap();
2871        let sid = created.id().to_string();
2872        let session_dir = created.dir().to_path_buf();
2873        drop(created);
2874        std::fs::write(
2875            trust_path(&session_dir),
2876            br#"{"mode":"eager","risks":{"eager":{"outside_workspace":"deny"}}}"#,
2877        )
2878        .unwrap();
2879        let valid = Session::open_existing_with_trust(
2880            root.path(),
2881            &sid,
2882            crate::trust::TrustConfig::default(),
2883        )
2884        .unwrap();
2885        assert_eq!(
2886            valid
2887                .trust_config()
2888                .resolve_risk(crate::trust::RiskKind::WorkspaceExternal),
2889            crate::trust::PolicyAction::Deny
2890        );
2891        drop(valid);
2892
2893        std::fs::write(
2894            trust_path(&session_dir),
2895            br#"{"mode":"eager","risks":{"eager":{"sandbox_violation":"deny","outside_workspace":"deny"}}}"#,
2896        )
2897        .unwrap();
2898        let error = match Session::open_existing_with_trust(
2899            root.path(),
2900            &sid,
2901            crate::trust::TrustConfig::default(),
2902        ) {
2903            Ok(_) => panic!("obsolete sandbox_violation risk was accepted"),
2904            Err(error) => error,
2905        };
2906
2907        assert!(
2908            matches!(error, SessionOpenError::Trust { source, .. } if source.to_string().contains("sandbox_violation"))
2909        );
2910    }
2911
2912    #[test]
2913    fn session_permission_pipeline_a_b_binds_each_broker_to_only_its_registry() {
2914        let root = TempDir::new().unwrap();
2915        let session_a = Session::open(root.path()).unwrap();
2916        let session_b = Session::open(root.path()).unwrap();
2917        assert!(
2918            session_a
2919                .permission_broker
2920                .is_for_registry(&session_a.flow_registry)
2921        );
2922        assert!(
2923            session_b
2924                .permission_broker
2925                .is_for_registry(&session_b.flow_registry)
2926        );
2927        assert!(
2928            !session_a
2929                .permission_broker
2930                .is_for_registry(&session_b.flow_registry)
2931        );
2932        assert!(
2933            !session_b
2934                .permission_broker
2935                .is_for_registry(&session_a.flow_registry)
2936        );
2937    }
2938
2939    #[tokio::test]
2940    async fn fresh_persistent_session_persists_permission_before_matching_stream_frame() {
2941        let root = TempDir::new().unwrap();
2942        let session = Session::open(root.path()).unwrap();
2943        let mut stream = session.stream_subscribe();
2944
2945        let request_id = submit_session_permission(&session);
2946
2947        assert!(session.sink().snapshot().iter().any(|event| matches!(
2948            event,
2949            crate::event::Event::PermissionRequestCreated { payload }
2950                if payload.request_id.as_ref() == Some(&request_id)
2951        )));
2952        session.flush_writer().await;
2953        let persisted = std::fs::read_to_string(session.dir().join("events.jsonl")).unwrap();
2954        let request_id_text = request_id.to_string();
2955        assert!(persisted.lines().any(|line| {
2956            let value: serde_json::Value = serde_json::from_str(line).unwrap();
2957            value["type"] == "permission_request_created"
2958                && value["payload"]["request_id"].as_str() == Some(request_id_text.as_str())
2959        }));
2960        assert!(matches!(
2961            stream.try_recv().unwrap(),
2962            StreamFrame::PermissionRequestCreated { payload, .. }
2963                if payload.request_id.as_ref() == Some(&request_id)
2964        ));
2965        session.shutdown().await;
2966    }
2967
2968    #[tokio::test]
2969    async fn reopened_and_ephemeral_sessions_install_permission_projectors() {
2970        let root = TempDir::new().unwrap();
2971        let created = Session::open(root.path()).unwrap();
2972        let sid = created.id().to_string();
2973        created.shutdown().await;
2974        drop(created);
2975
2976        let reopened = Session::open_existing(root.path(), &sid).unwrap();
2977        let mut reopened_stream = reopened.stream_subscribe();
2978        let reopened_id = submit_session_permission(&reopened);
2979        assert!(reopened.sink().snapshot().iter().any(|event| matches!(
2980            event,
2981            crate::event::Event::PermissionRequestCreated { payload }
2982                if payload.request_id.as_ref() == Some(&reopened_id)
2983        )));
2984        assert!(matches!(
2985            reopened_stream.try_recv().unwrap(),
2986            StreamFrame::PermissionRequestCreated { payload, .. }
2987                if payload.request_id.as_ref() == Some(&reopened_id)
2988        ));
2989        reopened.shutdown().await;
2990
2991        let ephemeral = Session::open_ephemeral();
2992        let mut ephemeral_stream = ephemeral.stream_subscribe();
2993        let ephemeral_id = submit_session_permission(&ephemeral);
2994        assert!(ephemeral.sink().snapshot().iter().any(|event| matches!(
2995            event,
2996            crate::event::Event::PermissionRequestCreated { payload }
2997                if payload.request_id.as_ref() == Some(&ephemeral_id)
2998        )));
2999        assert!(matches!(
3000            ephemeral_stream.try_recv().unwrap(),
3001            StreamFrame::PermissionRequestCreated { payload, .. }
3002                if payload.request_id.as_ref() == Some(&ephemeral_id)
3003        ));
3004    }
3005
3006    #[tokio::test]
3007    async fn reopening_session_does_not_hydrate_permission_broker_state() {
3008        let root = TempDir::new().unwrap();
3009        let created = Session::open(root.path()).unwrap();
3010        let sid = created.id().to_string();
3011        submit_session_permission(&created);
3012        created.flush_writer().await;
3013        created.shutdown().await;
3014        drop(created);
3015
3016        let reopened = Session::open_existing(root.path(), &sid).unwrap();
3017        assert!(reopened.permission_broker.list().is_empty());
3018        assert!(reopened.permission_broker.grants().is_empty());
3019        let actor = reopened
3020            .flow_registry
3021            .register_root(sid, crate::event::FlowRunId::now(), permission_authority())
3022            .unwrap();
3023        assert!(
3024            reopened
3025                .permission_broker
3026                .visible_group_list(&actor)
3027                .unwrap()
3028                .is_empty()
3029        );
3030        reopened.shutdown().await;
3031    }
3032
3033    #[test]
3034    fn update_trust_persists_then_notifies_subscribers() {
3035        let root = TempDir::new().unwrap();
3036        let session =
3037            Session::open_with_trust(root.path(), crate::trust::TrustConfig::default()).unwrap();
3038        let mut rx = session.subscribe_trust();
3039        let mut next = session.trust_config();
3040        next.mode = crate::trust::TrustMode::Eager;
3041
3042        session.update_trust(next.clone(), |_| Ok(())).unwrap();
3043
3044        assert!(rx.has_changed().unwrap());
3045        assert_eq!(*rx.borrow_and_update(), next);
3046        assert_eq!(read_trust(&session.dir).unwrap(), next);
3047    }
3048
3049    #[test]
3050    fn update_trust_rolls_back_session_when_global_persist_fails() {
3051        let root = TempDir::new().unwrap();
3052        let previous = crate::trust::TrustConfig::default();
3053        let session = Session::open_with_trust(root.path(), previous.clone()).unwrap();
3054        let rx = session.subscribe_trust();
3055        let mut next = previous.clone();
3056        next.mode = crate::trust::TrustMode::Reckless;
3057
3058        let error = session
3059            .update_trust(next, |_| Err(std::io::Error::other("global write failed")))
3060            .unwrap_err();
3061
3062        assert!(
3063            matches!(error, TrustUpdateError::Global(source) if source.to_string() == "global write failed")
3064        );
3065        assert!(!rx.has_changed().unwrap());
3066        assert_eq!(session.trust_config(), previous);
3067        assert_eq!(read_trust(&session.dir).unwrap(), previous);
3068    }
3069
3070    #[test]
3071    fn update_trust_reports_rollback_failure() {
3072        let root = TempDir::new().unwrap();
3073        let previous = crate::trust::TrustConfig::default();
3074        let session = Session::open_with_trust(root.path(), previous.clone()).unwrap();
3075        let trust_file = trust_path(session.dir());
3076        let mut next = previous;
3077        next.mode = crate::trust::TrustMode::Eager;
3078
3079        let error = session
3080            .update_trust(next, |_| {
3081                std::fs::remove_file(&trust_file).unwrap();
3082                std::fs::create_dir(&trust_file).unwrap();
3083                Err(std::io::Error::other("global write failed"))
3084            })
3085            .unwrap_err();
3086
3087        assert!(matches!(error, TrustUpdateError::RollbackFailed { .. }));
3088    }
3089
3090    #[test]
3091    fn successful_flow_count_triggers_at_powers_of_three_and_resets_per_session() {
3092        let session = Session::open_ephemeral();
3093        let mut hits = Vec::new();
3094        for _ in 0..27 {
3095            if let Some(count) = session.record_successful_flow() {
3096                hits.push(count);
3097            }
3098        }
3099        assert_eq!(hits, vec![3, 9, 27]);
3100        assert_eq!(session.successful_flow_count(), 27);
3101        assert_eq!(Session::open_ephemeral().successful_flow_count(), 0);
3102    }
3103
3104    #[test]
3105    fn commit_rewritten_window_uses_checkpoint_without_legacy_range_replay() {
3106        let session = Session::open_ephemeral();
3107        assert_eq!(session.context_epoch(), None);
3108        let original = vec![
3109            Message::user_text(TurnId::now(), "first user"),
3110            Message::assistant_text(TurnId::now(), "large output".repeat(2_000)),
3111            Message::user_text(TurnId::now(), "current user"),
3112        ];
3113        for message in original.clone() {
3114            session.append_message(message, None);
3115        }
3116        let replacement = vec![
3117            original[0].clone(),
3118            Message::assistant_text(TurnId::now(), "persisted omission"),
3119            original[2].clone(),
3120        ];
3121        let before_tokens = crate::compaction::estimate_tokens_for_messages(&original);
3122
3123        session
3124            .commit_rewritten_window(replacement.clone(), before_tokens, before_tokens, 1)
3125            .expect("rewrite commit");
3126
3127        assert_eq!(
3128            session.context_epoch(),
3129            Some(checkpoint_epoch_digest(&replacement))
3130        );
3131        assert_eq!(session.messages().as_ref(), replacement.as_slice());
3132        let events = session.sink().snapshot();
3133        assert!(events.iter().any(|event| matches!(
3134            event,
3135            Event::ContextCompact {
3136                replacement_msg_seq: None,
3137                ..
3138            }
3139        )));
3140        assert!(
3141            !events
3142                .iter()
3143                .any(|event| matches!(event, Event::SystemMsg { .. }))
3144        );
3145        let checkpoint = events
3146            .into_iter()
3147            .find(|event| matches!(event, Event::Checkpoint { .. }))
3148            .expect("checkpoint");
3149        let replay = crate::message_stream::MessageStream::new(std::sync::Arc::new(
3150            std::sync::Mutex::new(vec![crate::event::EventEnvelope::new(1, checkpoint)]),
3151        ));
3152        assert_eq!(&*replay.window(), replacement.as_slice());
3153    }
3154
3155    #[test]
3156    fn replayed_context_epoch_ignores_messages_appended_after_checkpoint() {
3157        let checkpoint = vec![Message::assistant_text(TurnId::now(), "rewritten")];
3158        let mut replay = checkpoint
3159            .iter()
3160            .cloned()
3161            .enumerate()
3162            .map(|(index, message)| (u64::MAX - index as u64, message))
3163            .collect::<Vec<_>>();
3164        let expected = replayed_checkpoint_epoch(&replay);
3165        replay.push((42, Message::user_text(TurnId::now(), "later")));
3166
3167        assert_eq!(replayed_checkpoint_epoch(&replay), expected);
3168        assert_eq!(expected, Some(checkpoint_epoch_digest(&checkpoint)));
3169    }
3170
3171    #[test]
3172    fn commit_compacted_window_updates_live_handle_and_checkpoint_replay() {
3173        let session = Session::open_ephemeral();
3174        let old = vec![
3175            Message::user_text(TurnId::now(), "old user".repeat(2_000)),
3176            Message::assistant_text(TurnId::now(), "old assistant".repeat(2_000)),
3177            Message::user_text(TurnId::now(), "current user"),
3178        ];
3179        for message in old.clone() {
3180            session.append_message(message, None);
3181        }
3182        let replacement = vec![
3183            Message::system_compact_summary(TurnId::now(), "anchor", 0, 1, 2),
3184            old[2].clone(),
3185            Message::assistant_text(TurnId::now(), "persisted omission"),
3186        ];
3187        let before_tokens = crate::compaction::estimate_tokens_for_messages(&old);
3188        let range = crate::compaction::CompactRange {
3189            start: 0,
3190            end: 2,
3191            tokens_saved_estimate: before_tokens,
3192        };
3193
3194        session
3195            .commit_compacted_window(
3196                "anchor".into(),
3197                replacement.clone(),
3198                range,
3199                before_tokens,
3200                before_tokens,
3201            )
3202            .expect("commit");
3203
3204        assert_eq!(session.messages().as_ref(), replacement.as_slice());
3205        assert_eq!(
3206            session.messages_handle().lock().unwrap().as_slice(),
3207            replacement.as_slice()
3208        );
3209        let checkpoint = session
3210            .sink()
3211            .snapshot()
3212            .into_iter()
3213            .find(|event| matches!(event, Event::Checkpoint { .. }))
3214            .expect("checkpoint");
3215        let replay = crate::message_stream::MessageStream::new(std::sync::Arc::new(
3216            std::sync::Mutex::new(vec![crate::event::EventEnvelope::new(1, checkpoint)]),
3217        ));
3218        assert_eq!(&*replay.window(), replacement.as_slice());
3219    }
3220
3221    #[test]
3222    fn replay_applies_attachment_degraded_patch() {
3223        let dir = TempDir::new().unwrap();
3224        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"}"#;
3225        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"}"#;
3226        write_events(dir.path(), &[user_msg, degrade]);
3227        let entries = replay_transcript_from(&dir.path().join("events.jsonl")).unwrap();
3228        let msg = entries
3229            .into_iter()
3230            .find_map(|e| match e {
3231                TranscriptEntry::Message { message, .. } => Some(message),
3232                _ => None,
3233            })
3234            .unwrap();
3235        assert_eq!(msg.parts.len(), 2);
3236        match &msg.parts[0] {
3237            crate::message::MessagePart::Text { text } => {
3238                assert!(text.contains("photo.png"), "expected basename: {text}");
3239                assert!(text.contains("image_too_large"), "expected reason: {text}");
3240                assert!(text.starts_with("[attachment unavailable"));
3241            }
3242            other => panic!("expected Text stub, got {other:?}"),
3243        }
3244        assert!(matches!(
3245            msg.parts[1],
3246            crate::message::MessagePart::Text { .. }
3247        ));
3248    }
3249
3250    #[test]
3251    fn approval_registry_always_queues_for_manual_decision() {
3252        let reg = std::sync::Arc::new(ApprovalRegistry::new());
3253        let pending = PendingApproval {
3254            tool_use_id: "tu42".into(),
3255            tool_name: "fs.write".into(),
3256            args_preview: "{}".into(),
3257            preview: None,
3258            level: crate::tool::ApprovalLevel::Approve,
3259            run_id: FlowRunId::now(),
3260            emitted_at: chrono::Utc::now(),
3261        };
3262        let mut rx = reg.request(pending);
3263        assert_eq!(reg.list_pending().len(), 1);
3264        assert!(rx.try_recv().is_err(), "should still be queued");
3265        assert!(reg.decide("tu42", ApprovalDecision::Approve));
3266        let got = rx.blocking_recv().unwrap();
3267        assert!(matches!(got, ApprovalDecision::Approve));
3268        assert!(reg.list_pending().is_empty());
3269    }
3270
3271    #[test]
3272    fn approval_registry_decide_all_flushes_queue() {
3273        let reg = ApprovalRegistry::new();
3274        let mut rxs = Vec::new();
3275        for i in 0..3 {
3276            rxs.push(reg.request(PendingApproval {
3277                tool_use_id: format!("tu{i}"),
3278                tool_name: "bash.exec".into(),
3279                args_preview: "{}".into(),
3280                preview: None,
3281                level: crate::tool::ApprovalLevel::Dangerous,
3282                run_id: FlowRunId::now(),
3283                emitted_at: chrono::Utc::now(),
3284            }));
3285        }
3286        assert_eq!(reg.list_pending().len(), 3);
3287        assert_eq!(
3288            reg.decide_all(ApprovalDecision::Deny {
3289                reason: "user cancelled".into()
3290            }),
3291            3
3292        );
3293        assert!(reg.list_pending().is_empty());
3294    }
3295
3296    #[test]
3297    fn compact_review_registry_auto_accepts_when_no_subscriber() {
3298        let reg = CompactReviewRegistry::new();
3299        let pending = PendingCompactReview {
3300            review_id: "r1".into(),
3301            summary: "gist".into(),
3302            slice_preview: String::new(),
3303            slice_count: 0,
3304            range_start: 0,
3305            range_end: 0,
3306            tokens_before: 0,
3307            emitted_at: chrono::Utc::now(),
3308        };
3309        let rx = reg.request(pending);
3310        let got = rx.blocking_recv().unwrap();
3311        assert!(matches!(got, CompactReviewDecision::AcceptAsIs));
3312        assert!(reg.list_pending().is_none());
3313    }
3314
3315    #[test]
3316    fn compact_review_registry_holds_pending_and_decides() {
3317        let reg = std::sync::Arc::new(CompactReviewRegistry::new());
3318        let _sub = reg.subscribe();
3319        let pending = PendingCompactReview {
3320            review_id: "r2".into(),
3321            summary: "old".into(),
3322            slice_preview: "slice".into(),
3323            slice_count: 3,
3324            range_start: 1,
3325            range_end: 4,
3326            tokens_before: 500,
3327            emitted_at: chrono::Utc::now(),
3328        };
3329        let mut rx = reg.request(pending);
3330        assert!(rx.try_recv().is_err(), "should be queued");
3331        assert!(reg.list_pending().is_some());
3332        assert!(reg.decide(
3333            "r2",
3334            CompactReviewDecision::AcceptEdited {
3335                summary: "new".into()
3336            }
3337        ));
3338        let got = rx.blocking_recv().unwrap();
3339        match got {
3340            CompactReviewDecision::AcceptEdited { summary } => assert_eq!(summary, "new"),
3341            other => panic!("unexpected decision: {other:?}"),
3342        }
3343        assert!(reg.list_pending().is_none());
3344    }
3345
3346    #[test]
3347    fn compact_review_registry_reject_flushes() {
3348        let reg = std::sync::Arc::new(CompactReviewRegistry::new());
3349        let _sub = reg.subscribe();
3350        let rx = reg.request(PendingCompactReview {
3351            review_id: "r3".into(),
3352            summary: String::new(),
3353            slice_preview: String::new(),
3354            slice_count: 0,
3355            range_start: 0,
3356            range_end: 0,
3357            tokens_before: 0,
3358            emitted_at: chrono::Utc::now(),
3359        });
3360        assert!(reg.decide("r3", CompactReviewDecision::Reject));
3361        let got = rx.blocking_recv().unwrap();
3362        assert!(matches!(got, CompactReviewDecision::Reject));
3363    }
3364
3365    #[test]
3366    fn replay_context_snapshot_accumulates_llm_call_usage() {
3367        let dir = TempDir::new().unwrap();
3368        let events = [
3369            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"}"#,
3370            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"}"#,
3371            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"}"#,
3372        ];
3373        write_events(dir.path(), &events);
3374        let snap = replay_context_snapshot_from(&dir.path().join("events.jsonl"));
3375        assert_eq!(snap.model, "anthropic/claude-4");
3376        assert_eq!(snap.provider, "anthropic");
3377        assert_eq!(snap.tokens_in, 310);
3378        assert_eq!(snap.tokens_out, 130);
3379        assert_eq!(snap.cache_read, 10);
3380        assert_eq!(snap.primary_usage().unwrap().tokens_in, 310);
3381    }
3382
3383    #[test]
3384    fn replay_context_snapshot_skips_subagent_llm_calls() {
3385        let dir = TempDir::new().unwrap();
3386        let events = [
3387            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"}"#,
3388            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"}"#,
3389        ];
3390        write_events(dir.path(), &events);
3391        let snap = replay_context_snapshot_from(&dir.path().join("events.jsonl"));
3392        assert_eq!(snap.model, "zhipuai/glm-5.2");
3393        assert_eq!(snap.tokens_in, 100);
3394        assert_eq!(snap.tokens_out, 50);
3395        assert_eq!(snap.usage_buckets.len(), 1);
3396    }
3397
3398    #[test]
3399    fn replay_context_snapshot_separates_explicit_helper_usage() {
3400        let dir = TempDir::new().unwrap();
3401        let events = [
3402            r#"{"type":"llm_call","seq":1,"model":"primary-model","provider":"primary-provider","context_call_purpose":"general","context_call_identity":{"scope":"root","session_id":"session"},"usage":{"input":20,"cached_input":80,"output":10,"cache_write":50},"wallclock_ms":1000,"ttft_ms":120,"tokens_per_second":20.0,"status":{"kind":"ok"},"run_id":"019f0000-0000-7000-0000-000000000099","ts":"2026-07-08T00:00:00Z"}"#,
3403            r#"{"type":"llm_call","seq":2,"model":"helper-model","provider":"helper-provider","context_call_purpose":"extraction","context_call_identity":{"scope":"detached"},"usage":{"input":1000,"cached_input":0,"output":80,"cache_write":0},"wallclock_ms":1000,"status":{"kind":"ok"},"run_id":null,"ts":"2026-07-08T00:00:01Z"}"#,
3404        ];
3405        write_events(dir.path(), &events);
3406        let snap = replay_context_snapshot_from(&dir.path().join("events.jsonl"));
3407
3408        assert_eq!(snap.model, "primary-model");
3409        assert_eq!(snap.provider, "primary-provider");
3410        assert_eq!(snap.tokens_in, 1_150);
3411        assert_eq!(snap.usage_buckets.len(), 2);
3412        let primary = snap.primary_usage().unwrap();
3413        assert_eq!(primary.tokens_in, 150);
3414        assert_eq!(primary.cache_read, 80);
3415        assert_eq!(snap.last_ttft_ms, 120);
3416    }
3417
3418    #[test]
3419    fn compact_review_mode_parses_all_variants() {
3420        assert_eq!(
3421            CompactReviewMode::parse("always"),
3422            Some(CompactReviewMode::Always)
3423        );
3424        assert_eq!(
3425            CompactReviewMode::parse("manual-only"),
3426            Some(CompactReviewMode::ManualOnly)
3427        );
3428        assert_eq!(
3429            CompactReviewMode::parse("manual_only"),
3430            Some(CompactReviewMode::ManualOnly)
3431        );
3432        assert_eq!(
3433            CompactReviewMode::parse("never"),
3434            Some(CompactReviewMode::Never)
3435        );
3436        assert_eq!(CompactReviewMode::parse(" bogus "), None);
3437    }
3438
3439    #[test]
3440    fn compact_review_mode_should_review_matrix() {
3441        assert!(CompactReviewMode::Always.should_review(false));
3442        assert!(CompactReviewMode::Always.should_review(true));
3443        assert!(!CompactReviewMode::ManualOnly.should_review(false));
3444        assert!(CompactReviewMode::ManualOnly.should_review(true));
3445        assert!(!CompactReviewMode::Never.should_review(false));
3446        assert!(!CompactReviewMode::Never.should_review(true));
3447    }
3448
3449    #[test]
3450    fn compact_review_registry_new_request_rejects_previous() {
3451        let reg = std::sync::Arc::new(CompactReviewRegistry::new());
3452        let _sub = reg.subscribe();
3453        let rx_a = reg.request(PendingCompactReview {
3454            review_id: "rA".into(),
3455            summary: String::new(),
3456            slice_preview: String::new(),
3457            slice_count: 0,
3458            range_start: 0,
3459            range_end: 0,
3460            tokens_before: 0,
3461            emitted_at: chrono::Utc::now(),
3462        });
3463        let _rx_b = reg.request(PendingCompactReview {
3464            review_id: "rB".into(),
3465            summary: String::new(),
3466            slice_preview: String::new(),
3467            slice_count: 0,
3468            range_start: 0,
3469            range_end: 0,
3470            tokens_before: 0,
3471            emitted_at: chrono::Utc::now(),
3472        });
3473        let got = rx_a.blocking_recv().unwrap();
3474        assert!(matches!(got, CompactReviewDecision::Reject));
3475    }
3476
3477    fn mk_form(form_id: &str, prompt: &str) -> crate::form::PendingForm {
3478        crate::form::PendingForm {
3479            form_id: form_id.into(),
3480            run_id: crate::event::FlowRunId::now(),
3481            tool_use_id: "tu".into(),
3482            kind: crate::form::FormKind::Confirm {
3483                prompt: prompt.into(),
3484            },
3485            form: crate::form::CompositeForm {
3486                questions: vec![crate::form::FormQuestion {
3487                    id: "question".into(),
3488                    kind: crate::form::FormKind::Confirm {
3489                        prompt: prompt.into(),
3490                    },
3491                }],
3492            },
3493            emitted_at: chrono::Utc::now(),
3494        }
3495    }
3496
3497    #[test]
3498    fn form_registry_auto_cancels_without_subscriber() {
3499        let reg = FormRegistry::new();
3500        let rx = reg.request(mk_form("f1", "sure?"));
3501        let got = rx.blocking_recv().unwrap();
3502        assert_eq!(got, crate::form::FormSubmission::Rejected);
3503        assert!(reg.list_pending().is_empty());
3504    }
3505
3506    #[test]
3507    fn form_registry_delivers_answer_by_form_id() {
3508        let reg = std::sync::Arc::new(FormRegistry::new());
3509        let _sub = reg.subscribe();
3510        let rx = reg.request(mk_form("fA", "?"));
3511        assert_eq!(reg.list_pending().len(), 1);
3512        let ok = reg.submit(
3513            "fA",
3514            crate::form::FormSubmission::Submitted {
3515                answers: vec![crate::form::FormAnswer::Confirmed { value: true }],
3516            },
3517        );
3518        assert!(ok);
3519        let got = rx.blocking_recv().unwrap();
3520        assert_eq!(
3521            got,
3522            crate::form::FormSubmission::Submitted {
3523                answers: vec![crate::form::FormAnswer::Confirmed { value: true }],
3524            }
3525        );
3526        assert!(reg.list_pending().is_empty());
3527    }
3528
3529    #[test]
3530    fn form_registry_submit_unknown_id_is_noop() {
3531        let reg = std::sync::Arc::new(FormRegistry::new());
3532        let _sub = reg.subscribe();
3533        let _rx = reg.request(mk_form("real", "?"));
3534        assert!(!reg.submit("ghost", crate::form::FormSubmission::Rejected));
3535        assert_eq!(reg.list_pending().len(), 1);
3536    }
3537
3538    #[test]
3539    fn form_registry_cancel_removes_one_pending_form() {
3540        let reg = std::sync::Arc::new(FormRegistry::new());
3541        let _sub = reg.subscribe();
3542        let rx = reg.request(mk_form("cancel", "?"));
3543        assert!(reg.cancel("cancel"));
3544        assert_eq!(
3545            rx.blocking_recv().unwrap(),
3546            crate::form::FormSubmission::Rejected
3547        );
3548        assert!(reg.list_pending().is_empty());
3549    }
3550
3551    #[test]
3552    fn form_registry_cancel_all_flushes_pending() {
3553        let reg = std::sync::Arc::new(FormRegistry::new());
3554        let _sub = reg.subscribe();
3555        let rx_a = reg.request(mk_form("a", "?"));
3556        let rx_b = reg.request(mk_form("b", "?"));
3557        reg.cancel_all();
3558        assert_eq!(
3559            rx_a.blocking_recv().unwrap(),
3560            crate::form::FormSubmission::Rejected
3561        );
3562        assert_eq!(
3563            rx_b.blocking_recv().unwrap(),
3564            crate::form::FormSubmission::Rejected
3565        );
3566        assert!(reg.list_pending().is_empty());
3567    }
3568
3569    #[test]
3570    fn form_registry_queues_multiple_pending() {
3571        let reg = std::sync::Arc::new(FormRegistry::new());
3572        let _sub = reg.subscribe();
3573        let _rx1 = reg.request(mk_form("1", "?"));
3574        let _rx2 = reg.request(mk_form("2", "?"));
3575        let pending = reg.list_pending();
3576        assert_eq!(pending.len(), 2);
3577        assert_eq!(pending[0].form_id, "1");
3578        assert_eq!(pending[1].form_id, "2");
3579    }
3580
3581    #[test]
3582    fn replay_without_degraded_events_preserves_image_parts() {
3583        let dir = TempDir::new().unwrap();
3584        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"}"#;
3585        write_events(dir.path(), &[user_msg]);
3586        let entries = replay_transcript_from(&dir.path().join("events.jsonl")).unwrap();
3587        let msg = entries
3588            .into_iter()
3589            .find_map(|e| match e {
3590                TranscriptEntry::Message { message, .. } => Some(message),
3591                _ => None,
3592            })
3593            .unwrap();
3594        assert!(matches!(
3595            msg.parts[0],
3596            crate::message::MessagePart::Image { .. }
3597        ));
3598    }
3599
3600    #[test]
3601    fn attachment_degrade_updates_only_the_target_user_message() {
3602        fn image_message(path: &str) -> Message {
3603            Message {
3604                role: MessageRole::User,
3605                parts: vec![crate::message::MessagePart::Image {
3606                    source: crate::message::ImageSource {
3607                        media_type: "image/png".into(),
3608                        data: crate::message::ImageData::Path { path: path.into() },
3609                        detail: crate::provider::ImageDetail::Auto,
3610                    },
3611                }],
3612                turn_id: TurnId::now(),
3613                origin: crate::message::MessageOrigin::User,
3614            }
3615        }
3616
3617        let session = Session::open_ephemeral();
3618        session.append_message(image_message("/tmp/first.png"), None);
3619        session.append_message(image_message("/tmp/second.png"), None);
3620
3621        assert_eq!(session.record_attachment_degrade("invalid_image"), 1);
3622        let messages = session.messages_handle();
3623        let messages = messages.lock().unwrap();
3624        assert!(matches!(
3625            messages[0].parts[0],
3626            crate::message::MessagePart::Image { .. }
3627        ));
3628        assert!(matches!(
3629            &messages[1].parts[0],
3630            crate::message::MessagePart::Text { text } if text.contains("second.png")
3631        ));
3632    }
3633
3634    #[test]
3635    fn replay_messages_from_old_format_no_seq_no_ts() {
3636        let dir = TempDir::new().unwrap();
3637        // Old-style JSONL: no seq, no ts on events
3638        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"}}"#;
3639        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}"#;
3640        write_events(dir.path(), &[user_json, asst_json]);
3641        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
3642        assert_eq!(msgs.len(), 2, "should load both messages from old format");
3643        assert_eq!(msgs[0].text_concat(), "hello");
3644        assert_eq!(msgs[1].text_concat(), "hi there");
3645    }
3646
3647    #[test]
3648    fn replay_messages_from_old_format_with_null_fields() {
3649        let dir = TempDir::new().unwrap();
3650        // Old JSON with null turn_id / flow_run_id (graceful parse)
3651        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"}}"#;
3652        write_events(dir.path(), &[sys_json]);
3653        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
3654        assert_eq!(msgs.len(), 1);
3655        assert_eq!(msgs[0].text_concat(), "note");
3656    }
3657
3658    #[test]
3659    fn replay_messages_from_applies_attachment_degrade_event() {
3660        let dir = TempDir::new().unwrap();
3661        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"}"#;
3662        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"}"#;
3663        write_events(dir.path(), &[user_msg, degrade]);
3664        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
3665        assert_eq!(msgs.len(), 1, "only the user message");
3666        assert_eq!(msgs[0].parts.len(), 2);
3667        assert!(matches!(
3668            &msgs[0].parts[0],
3669            crate::message::MessagePart::Text { text }
3670                if text.contains("photo.png") && text.contains("image_too_large")
3671        ));
3672        assert!(
3673            matches!(msgs[0].parts[1], crate::message::MessagePart::Text { .. }),
3674            "second part should remain text"
3675        );
3676    }
3677
3678    #[test]
3679    fn replay_messages_from_degrade_before_message_is_noop() {
3680        let dir = TempDir::new().unwrap();
3681        // Degrade event appears BEFORE the message it references (should not crash)
3682        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"}"#;
3683        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"}"#;
3684        write_events(dir.path(), &[degrade, user_msg]);
3685        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
3686        assert_eq!(msgs.len(), 1);
3687        // Image part preserved — degrade referenced unknown message_seq
3688        assert!(
3689            matches!(msgs[0].parts[0], crate::message::MessagePart::Image { .. }),
3690            "image should remain when degrade targets unknown seq"
3691        );
3692    }
3693
3694    #[test]
3695    fn replay_messages_from_degrade_wrong_seq_leaves_image() {
3696        let dir = TempDir::new().unwrap();
3697        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"}"#;
3698        // Degrade references wrong message_seq (2, but message has seq 1)
3699        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"}"#;
3700        write_events(dir.path(), &[user_msg, degrade]);
3701        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
3702        assert_eq!(msgs.len(), 1);
3703        assert!(
3704            matches!(msgs[0].parts[0], crate::message::MessagePart::Image { .. }),
3705            "image should remain when degrade targets wrong seq"
3706        );
3707    }
3708
3709    #[test]
3710    fn replay_messages_from_applies_context_compact() {
3711        let dir = TempDir::new().unwrap();
3712        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"}"#;
3713        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"}"#;
3714        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"}"#;
3715        // replacement message (compact summary)
3716        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"}"#;
3717        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"}"#;
3718        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"}"#;
3719        write_events(dir.path(), &[user1, asst1, user2, summary, compact, after]);
3720        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
3721        // compact range 0-1 removes user1+asst1; user2 (outside range) + summary + after = 3
3722        assert_eq!(msgs.len(), 3, "compact summary + user2 + after compact");
3723        assert!(
3724            matches!(
3725                msgs[0].parts[0],
3726                crate::message::MessagePart::CompactSummary { .. }
3727            ),
3728            "first should be compact summary"
3729        );
3730        if let crate::message::MessagePart::CompactSummary { summary, .. } = &msgs[0].parts[0] {
3731            assert_eq!(summary, "two messages compacted");
3732        }
3733        assert_eq!(msgs[1].text_concat(), "old u2");
3734        assert_eq!(msgs[2].text_concat(), "after compact");
3735    }
3736
3737    #[test]
3738    fn replay_messages_from_no_replacement_seq_ignores_compact() {
3739        let dir = TempDir::new().unwrap();
3740        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"}"#;
3741        // context_compact with no replacement_msg_seq → should be ignored
3742        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"}"#;
3743        write_events(dir.path(), &[user1, compact]);
3744        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
3745        assert_eq!(msgs.len(), 1, "compact without replacement seq is ignored");
3746        assert_eq!(msgs[0].text_concat(), "hello");
3747    }
3748
3749    #[test]
3750    fn replay_messages_from_compact_after_no_change_ignored() {
3751        let dir = TempDir::new().unwrap();
3752        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"}"#;
3753        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"}"#;
3754        // after_tokens >= before_tokens → compaction is no-op
3755        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"}"#;
3756        write_events(dir.path(), &[user1, summary, compact]);
3757        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
3758        assert_eq!(msgs.len(), 2, "compact with after>=before is ignored");
3759    }
3760
3761    #[test]
3762    fn replay_messages_from_missing_file_returns_empty() {
3763        let dir = TempDir::new().unwrap();
3764        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
3765        assert!(msgs.is_empty());
3766    }
3767
3768    #[test]
3769    fn replay_messages_from_empty_file_returns_empty() {
3770        let dir = TempDir::new().unwrap();
3771        write_events(dir.path(), &[]);
3772        let msgs = replay_messages_from(&dir.path().join("events.jsonl")).unwrap();
3773        assert!(msgs.is_empty());
3774    }
3775
3776    #[test]
3777    fn replay_all_messages_with_seq_includes_compacted() {
3778        let dir = TempDir::new().unwrap();
3779        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"}"#;
3780        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"}"#;
3781        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"}"#;
3782        write_events(dir.path(), &[user1, summary, compact]);
3783        let all = replay_all_messages_with_seq(&dir.path().join("events.jsonl")).unwrap();
3784        // all includes both user1 and summary — compaction NOT applied
3785        assert_eq!(all.len(), 2, "all messages preserved (no compaction)");
3786        assert_eq!(all[0].1.text_concat(), "old");
3787    }
3788}