| 1 | //! One typed, capped, marker-stable ModelContext fragment. |
| 2 | |
| 3 | use std::collections::hash_map::DefaultHasher; |
| 4 | use std::hash::{Hash, Hasher}; |
| 5 | |
| 6 | /// Default hard byte cap per volatile fragment. Keeps WorldState from |
| 7 | /// displacing the cache-stable constitution prefix under fanout noise. |
| 8 | pub const DEFAULT_FRAGMENT_MAX_BYTES: usize = 4 * 1024; |
| 9 | |
| 10 | /// Stable identity for a WorldState concern. Markers are public contract — |
| 11 | /// do not rename without a migration note (prefix-cache + tests pin them). |
| 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 13 | pub enum FragmentId { |
| 14 | Workspace, |
| 15 | Permissions, |
| 16 | Route, |
| 17 | AgentTopology, |
| 18 | SkillsTools, |
| 19 | TokenBudget, |
| 20 | } |
| 21 | |
| 22 | impl FragmentId { |
| 23 | #[must_use] |
| 24 | #[allow(dead_code)] // public identity API for WorldState host adapters (TUI-DOG-011) |
| 25 | pub fn as_str(self) -> &'static str { |
| 26 | match self { |
| 27 | Self::Workspace => "workspace", |
| 28 | Self::Permissions => "permissions", |
| 29 | Self::Route => "route", |
| 30 | Self::AgentTopology => "agent_topology", |
| 31 | Self::SkillsTools => "skills_tools", |
| 32 | Self::TokenBudget => "token_budget", |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | /// Stable HTML-comment marker wrapping the fragment body. |
| 37 | #[must_use] |
| 38 | pub fn marker(self) -> &'static str { |
| 39 | match self { |
| 40 | Self::Workspace => "<!-- cw:ctx:workspace -->", |
| 41 | Self::Permissions => "<!-- cw:ctx:permissions -->", |
| 42 | Self::Route => "<!-- cw:ctx:route -->", |
| 43 | Self::AgentTopology => "<!-- cw:ctx:agent_topology -->", |
| 44 | Self::SkillsTools => "<!-- cw:ctx:skills_tools -->", |
| 45 | Self::TokenBudget => "<!-- cw:ctx:token_budget -->", |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | #[must_use] |
| 50 | #[allow(dead_code)] // public identity API for WorldState host adapters (TUI-DOG-011) |
| 51 | pub fn role(self) -> FragmentRole { |
| 52 | match self { |
| 53 | Self::Workspace => FragmentRole::Workspace, |
| 54 | Self::Permissions => FragmentRole::Permissions, |
| 55 | Self::Route => FragmentRole::Route, |
| 56 | Self::AgentTopology => FragmentRole::AgentTopology, |
| 57 | Self::SkillsTools => FragmentRole::SkillsTools, |
| 58 | Self::TokenBudget => FragmentRole::TokenBudget, |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | #[must_use] |
| 63 | #[allow(dead_code)] // ordered enumeration for host rebuilds / inspectors (TUI-DOG-011) |
| 64 | pub fn all() -> &'static [FragmentId] { |
| 65 | &[ |
| 66 | Self::Workspace, |
| 67 | Self::Permissions, |
| 68 | Self::Route, |
| 69 | Self::AgentTopology, |
| 70 | Self::SkillsTools, |
| 71 | Self::TokenBudget, |
| 72 | ] |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /// Explicit role of a fragment relative to the cache-stable constitution. |
| 77 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
| 78 | pub enum FragmentRole { |
| 79 | /// Workspace / repo working-set facts (volatile across sessions). |
| 80 | Workspace, |
| 81 | /// Approval / permission posture. |
| 82 | Permissions, |
| 83 | /// Active route, model, and app mode. |
| 84 | Route, |
| 85 | /// Sub-agent topology and recent completion notices. |
| 86 | AgentTopology, |
| 87 | /// Skills, plugins, and tool availability summary. |
| 88 | SkillsTools, |
| 89 | /// Token budget and compaction status. |
| 90 | TokenBudget, |
| 91 | } |
| 92 | |
| 93 | impl FragmentRole { |
| 94 | #[must_use] |
| 95 | #[allow(dead_code)] // public role labels for inspectors / diffs (TUI-DOG-011) |
| 96 | pub fn as_str(self) -> &'static str { |
| 97 | match self { |
| 98 | Self::Workspace => "workspace", |
| 99 | Self::Permissions => "permissions", |
| 100 | Self::Route => "route", |
| 101 | Self::AgentTopology => "agent_topology", |
| 102 | Self::SkillsTools => "skills_tools", |
| 103 | Self::TokenBudget => "token_budget", |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | /// Result of comparing a fragment against its previous render. |
| 109 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 110 | pub enum FragmentRender { |
| 111 | /// Content hash matches previous — retain bytes; do not reinject. |
| 112 | Unchanged { marker: String, content_hash: u64 }, |
| 113 | /// New or changed content — inject the capped body. |
| 114 | Updated { fragment: ModelContextFragment }, |
| 115 | /// Fragment was present before and is now absent. |
| 116 | #[allow(dead_code)] // produced by WorldState::clear; hosts wire clear next (TUI-DOG-011) |
| 117 | Cleared { marker: String }, |
| 118 | } |
| 119 | |
| 120 | /// One capped WorldState section with a stable marker and content hash. |
| 121 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 122 | pub struct ModelContextFragment { |
| 123 | pub id: FragmentId, |
| 124 | pub role: FragmentRole, |
| 125 | pub marker: &'static str, |
| 126 | pub max_bytes: usize, |
| 127 | pub content: String, |
| 128 | pub content_hash: u64, |
| 129 | } |
| 130 | |
| 131 | impl ModelContextFragment { |
| 132 | #[must_use] |
| 133 | pub fn new(id: FragmentId, role: FragmentRole, raw: impl Into<String>) -> Self { |
| 134 | Self::with_max_bytes(id, role, raw, DEFAULT_FRAGMENT_MAX_BYTES) |
| 135 | } |
| 136 | |
| 137 | #[must_use] |
| 138 | pub fn with_max_bytes( |
| 139 | id: FragmentId, |
| 140 | role: FragmentRole, |
| 141 | raw: impl Into<String>, |
| 142 | max_bytes: usize, |
| 143 | ) -> Self { |
| 144 | let content = enforce_byte_cap(raw.into(), max_bytes); |
| 145 | let content_hash = hash_content(&content); |
| 146 | Self { |
| 147 | id, |
| 148 | role, |
| 149 | marker: id.marker(), |
| 150 | max_bytes, |
| 151 | content, |
| 152 | content_hash, |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | /// Compare against a previous fragment of the same id. |
| 157 | #[must_use] |
| 158 | pub fn render_diff(&self, previous: Option<&Self>) -> FragmentRender { |
| 159 | match previous { |
| 160 | Some(prev) if prev.content_hash == self.content_hash && prev.marker == self.marker => { |
| 161 | FragmentRender::Unchanged { |
| 162 | marker: self.marker.to_string(), |
| 163 | content_hash: self.content_hash, |
| 164 | } |
| 165 | } |
| 166 | _ => FragmentRender::Updated { |
| 167 | fragment: self.clone(), |
| 168 | }, |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | /// Full render including the stable marker header. |
| 173 | #[must_use] |
| 174 | pub fn render_marked(&self) -> String { |
| 175 | format!("{}\n{}", self.marker, self.content.trim_end()) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | fn hash_content(content: &str) -> u64 { |
| 180 | let mut hasher = DefaultHasher::new(); |
| 181 | content.hash(&mut hasher); |
| 182 | hasher.finish() |
| 183 | } |
| 184 | |
| 185 | fn enforce_byte_cap(raw: String, max_bytes: usize) -> String { |
| 186 | if max_bytes == 0 { |
| 187 | return String::new(); |
| 188 | } |
| 189 | if raw.len() <= max_bytes { |
| 190 | return raw; |
| 191 | } |
| 192 | let omitted = raw.len().saturating_sub(max_bytes); |
| 193 | let marker = format!("\n[…truncated: {omitted} bytes omitted]"); |
| 194 | if marker.len() >= max_bytes { |
| 195 | return marker.chars().take(max_bytes).collect(); |
| 196 | } |
| 197 | let keep = max_bytes.saturating_sub(marker.len()); |
| 198 | // Truncate on a char boundary. |
| 199 | let mut end = keep; |
| 200 | while end > 0 && !raw.is_char_boundary(end) { |
| 201 | end -= 1; |
| 202 | } |
| 203 | let mut out = raw[..end].to_string(); |
| 204 | out.push_str(&marker); |
| 205 | out |
| 206 | } |
| 207 | |
| 208 | #[cfg(test)] |
| 209 | mod tests { |
| 210 | use super::*; |
| 211 | |
| 212 | #[test] |
| 213 | fn render_diff_detects_change_and_retain() { |
| 214 | let a = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=a"); |
| 215 | let b = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=a"); |
| 216 | let c = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=b"); |
| 217 | assert!(matches!( |
| 218 | b.render_diff(Some(&a)), |
| 219 | FragmentRender::Unchanged { .. } |
| 220 | )); |
| 221 | assert!(matches!( |
| 222 | c.render_diff(Some(&a)), |
| 223 | FragmentRender::Updated { .. } |
| 224 | )); |
| 225 | assert!(matches!( |
| 226 | a.render_diff(None), |
| 227 | FragmentRender::Updated { .. } |
| 228 | )); |
| 229 | } |
| 230 | } |
| 231 |