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