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