Skip to main content

atman_runtime/
session.rs

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