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