1use serde_json::{json, Value};
21
22pub const CONTEXT_USAGE_REQUEST_PREFIX: &str = "agentmux-context-usage-";
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct AgentContextUsage {
29 pub model: Option<String>,
32 pub auto_compact_window: Option<u64>,
35 pub auto_compact_threshold: Option<u64>,
37 pub auto_compact_enabled: bool,
38}
39
40pub fn context_usage_request_line() -> String {
43 json!({
44 "type": "control_request",
45 "request_id": format!("{CONTEXT_USAGE_REQUEST_PREFIX}{}", uuid::Uuid::new_v4()),
46 "request": { "subtype": "get_context_usage", "detail": "summary" },
47 })
48 .to_string()
49}
50
51fn count(v: Option<&Value>) -> Option<u64> {
53 v.and_then(Value::as_u64)
54 .filter(|n| *n > 0 && *n <= 100_000_000)
55}
56
57pub fn context_usage_from_control_response(frame: &Value) -> Option<AgentContextUsage> {
63 if frame.get("type").and_then(Value::as_str) != Some("control_response") {
64 return None;
65 }
66 let resp = frame.get("response")?;
67 if resp.get("subtype").and_then(Value::as_str) != Some("success") {
68 return None;
69 }
70 if !resp
71 .get("request_id")
72 .and_then(Value::as_str)
73 .is_some_and(|id| id.starts_with(CONTEXT_USAGE_REQUEST_PREFIX))
74 {
75 return None;
76 }
77 let body = resp.get("response")?.as_object()?;
78 let enabled = body.get("isAutoCompactEnabled")?.as_bool()?;
79 Some(AgentContextUsage {
80 model: body
81 .get("model")
82 .and_then(Value::as_str)
83 .filter(|m| !m.is_empty())
84 .map(str::to_string),
85 auto_compact_window: count(body.get("maxTokens")),
86 auto_compact_threshold: if enabled {
88 count(body.get("autoCompactThreshold"))
89 } else {
90 None
91 },
92 auto_compact_enabled: enabled,
93 })
94}
95
96#[derive(Debug, Clone, PartialEq, serde::Serialize, ts_rs::TS)]
99#[ts(export, export_to = "../../../frontend/types/rpc/")]
100pub struct AgentContextUsageEvent {
101 pub blockid: String,
102 pub model: Option<String>,
103 #[ts(type = "number | null")]
104 pub auto_compact_window: Option<u64>,
105 #[ts(type = "number | null")]
106 pub auto_compact_threshold: Option<u64>,
107 pub auto_compact_enabled: bool,
108}
109
110pub fn agent_context_usage_event(block_id: &str, usage: &AgentContextUsage) -> AgentContextUsageEvent {
112 AgentContextUsageEvent {
113 blockid: block_id.to_string(),
114 model: usage.model.clone(),
115 auto_compact_window: usage.auto_compact_window,
116 auto_compact_threshold: usage.auto_compact_threshold,
117 auto_compact_enabled: usage.auto_compact_enabled,
118 }
119}
120
121pub fn publish_agent_context_usage(
123 broker: &crate::backend::mps::Broker,
124 block_id: &str,
125 usage: &AgentContextUsage,
126) {
127 broker.publish_event::<crate::backend::mps_events::AgentContextUsageChanged>(
128 &agent_context_usage_event(block_id, usage),
129 vec![format!("block:{block_id}")],
130 1,
131 );
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 fn answer(request_id: &str, body: Value) -> Value {
139 json!({
140 "type": "control_response",
141 "response": { "subtype": "success", "request_id": request_id, "response": body },
142 })
143 }
144
145 fn ours() -> String {
146 format!("{CONTEXT_USAGE_REQUEST_PREFIX}abc")
147 }
148
149 #[test]
150 fn the_request_is_a_local_summary_with_our_prefix() {
151 let line: Value = serde_json::from_str(&context_usage_request_line()).unwrap();
152 assert_eq!(line["type"], "control_request");
153 assert_eq!(line["request"]["subtype"], "get_context_usage");
154 assert_eq!(line["request"]["detail"], "summary");
155 assert!(line["request_id"]
156 .as_str()
157 .unwrap()
158 .starts_with(CONTEXT_USAGE_REQUEST_PREFIX));
159 }
160
161 #[test]
162 fn reads_the_default_answer() {
163 let frame = answer(
165 &ours(),
166 json!({
167 "model": "claude-sonnet-5-5", "maxTokens": 1_000_000, "rawMaxTokens": 1_000_000,
168 "autoCompactThreshold": 967_000, "isAutoCompactEnabled": true,
169 "autocompactSource": "model-default", "totalTokens": 37_475, "categories": [],
170 }),
171 );
172 assert_eq!(
173 context_usage_from_control_response(&frame),
174 Some(AgentContextUsage {
175 model: Some("claude-sonnet-5-5".into()),
176 auto_compact_window: Some(1_000_000),
177 auto_compact_threshold: Some(967_000),
178 auto_compact_enabled: true,
179 })
180 );
181 }
182
183 #[test]
184 fn reads_an_overridden_window() {
185 let frame = answer(
187 &ours(),
188 json!({ "model": "claude-sonnet-5-5", "maxTokens": 300_000, "autoCompactThreshold": 267_000,
189 "isAutoCompactEnabled": true, "autocompactSource": "env" }),
190 );
191 let u = context_usage_from_control_response(&frame).unwrap();
192 assert_eq!(
193 (u.auto_compact_window, u.auto_compact_threshold),
194 (Some(300_000), Some(267_000))
195 );
196 }
197
198 #[test]
199 fn disabled_has_no_threshold() {
200 let frame = answer(
201 &ours(),
202 json!({ "model": "claude-sonnet-5-5", "maxTokens": 1_000_000, "autoCompactThreshold": 967_000,
203 "isAutoCompactEnabled": false }),
204 );
205 let u = context_usage_from_control_response(&frame).unwrap();
206 assert!(!u.auto_compact_enabled);
207 assert_eq!(u.auto_compact_threshold, None);
208 }
209
210 #[test]
211 fn ignores_other_answers_errors_and_junk() {
212 let body = json!({ "model": "m", "maxTokens": 200_000, "autoCompactThreshold": 167_000, "isAutoCompactEnabled": true });
213 assert_eq!(
215 context_usage_from_control_response(&answer("agentmux-settings-x", body.clone())),
216 None
217 );
218 let err = json!({ "type": "control_response",
220 "response": { "subtype": "error", "request_id": ours(), "error": "Unsupported" } });
221 assert_eq!(context_usage_from_control_response(&err), None);
222 assert_eq!(
224 context_usage_from_control_response(&json!({ "type": "result" })),
225 None
226 );
227 assert_eq!(
229 context_usage_from_control_response(&answer(&ours(), json!({ "maxTokens": 1 }))),
230 None
231 );
232 let u = context_usage_from_control_response(&answer(
234 &ours(),
235 json!({ "maxTokens": 0, "autoCompactThreshold": -5, "isAutoCompactEnabled": true }),
236 ))
237 .unwrap();
238 assert_eq!(
239 (u.auto_compact_window, u.auto_compact_threshold),
240 (None, None)
241 );
242 }
243
244 #[test]
245 fn the_event_carries_the_fields_snake_cased() {
246 let u = AgentContextUsage {
247 model: Some("claude-haiku-4-5-20251001".into()),
248 auto_compact_window: Some(200_000),
249 auto_compact_threshold: Some(167_000),
250 auto_compact_enabled: true,
251 };
252 assert_eq!(
253 serde_json::to_value(agent_context_usage_event("blk", &u)).unwrap(),
254 json!({ "blockid": "blk", "model": "claude-haiku-4-5-20251001", "auto_compact_window": 200_000,
255 "auto_compact_threshold": 167_000, "auto_compact_enabled": true })
256 );
257 }
258}