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