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