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