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}