Skip to main content

codetether_agent/session/context/
helpers.rs

1//! Shared types and tiny helpers used across the context derivation modules.
2
3use crate::provider::Message;
4use crate::session::ResidencyLevel;
5
6/// The per-step LLM context, derived from an append-only chat history.
7///
8/// This is the object the prompt loop should hand to the provider — *not*
9/// [`Session::messages`](crate::session::Session::messages) directly.
10///
11/// # Fields
12///
13/// * `messages` — The message list to include in the completion request.
14/// * `origin_len` — Length of the source history at the moment of derivation.
15/// * `compressed` — Whether compression fired during this derivation.
16/// * `dropped_ranges` — Best-effort ranges elided from the working context.
17/// * `provenance` — Pipeline steps that produced this context.
18/// * `resolutions` — Per-message residency levels in the derived context.
19///
20/// # Examples
21///
22/// ```rust
23/// use codetether_agent::session::context::DerivedContext;
24///
25/// let derived = DerivedContext {
26///     messages: Vec::new(),
27///     origin_len: 0,
28///     compressed: false,
29///     dropped_ranges: Vec::new(),
30///     provenance: Vec::new(),
31///     resolutions: Vec::new(),
32/// };
33/// assert_eq!(derived.origin_len, 0);
34/// assert!(!derived.compressed);
35/// ```
36#[derive(Debug, Clone)]
37pub struct DerivedContext {
38    /// Messages to send to the provider this turn.
39    pub messages: Vec<Message>,
40    /// `session.messages.len()` at the moment of derivation.
41    pub origin_len: usize,
42    /// `true` when any compression / truncation pass rewrote the clone.
43    pub compressed: bool,
44    /// Best-effort elided ranges from the source history.
45    pub dropped_ranges: Vec<(usize, usize)>,
46    /// Names of the pipeline stages that shaped this context.
47    pub provenance: Vec<String>,
48    /// Per-message residency level in the derived context.
49    pub resolutions: Vec<ResidencyLevel>,
50}
51
52/// Compare two message counts and return whether compression fired.
53///
54/// Separated out so the comparison is testable without a full provider
55/// round-trip. Any count change is treated as evidence that compression
56/// fired.
57pub(super) fn messages_len_changed(before: usize, after: &[Message]) -> bool {
58    before != after.len()
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::provider::{ContentPart, Role};
65
66    #[test]
67    fn derived_context_record_round_trips() {
68        let ctx = DerivedContext {
69            messages: vec![Message {
70                role: Role::User,
71                content: vec![ContentPart::Text {
72                    text: "hi".to_string(),
73                }],
74            }],
75            origin_len: 1,
76            compressed: false,
77            dropped_ranges: Vec::new(),
78            provenance: Vec::new(),
79            resolutions: vec![ResidencyLevel::Full],
80        };
81        let cloned = ctx.clone();
82        assert_eq!(ctx.origin_len, cloned.origin_len);
83        assert_eq!(ctx.compressed, cloned.compressed);
84        assert_eq!(ctx.messages.len(), cloned.messages.len());
85        assert_eq!(ctx.resolutions, cloned.resolutions);
86    }
87
88    #[test]
89    fn messages_len_changed_detects_shrink_and_noop() {
90        let empty: Vec<Message> = Vec::new();
91        assert!(!messages_len_changed(0, &empty));
92
93        let one = vec![Message {
94            role: Role::User,
95            content: vec![ContentPart::Text {
96                text: "x".to_string(),
97            }],
98        }];
99        assert!(messages_len_changed(5, &one));
100        assert!(!messages_len_changed(1, &one));
101    }
102}