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 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 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 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 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}