agentmux_bashwrap/
precompact.rs1use agentmux_common::client_events::{CompactionStarted, CompactionTrigger};
38use anyhow::Result;
39use serde::Deserialize;
40use std::io::Read;
41
42use crate::mps_client::WpsClient;
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
47pub enum Trigger {
48 Manual,
49 Auto,
50}
51
52impl Trigger {
53 fn as_str(self) -> &'static str {
54 match self {
55 Trigger::Manual => "manual",
56 Trigger::Auto => "auto",
57 }
58 }
59}
60
61#[derive(clap::Parser, Debug)]
63pub struct Args {
64 #[arg(long, value_enum)]
68 pub trigger: Trigger,
69}
70
71#[derive(Deserialize, Default)]
76struct PreCompactInput {
77 #[serde(default)]
78 session_id: String,
79}
80
81pub async fn run(args: Args) -> Result<()> {
85 let mut buf = String::new();
86 let _ = std::io::stdin().read_to_string(&mut buf);
89 let session_id = parse_session_id(&buf);
90
91 let Some(client) = WpsClient::from_env() else {
92 tracing::debug!(
93 target: "bashwrap",
94 "precompact: MPS env absent, skipping publish"
95 );
96 return Ok(());
97 };
98
99 let block_id = std::env::var("AGENTMUX_BLOCKID")
103 .ok()
104 .filter(|v| !v.is_empty());
105
106 let payload = build_payload(args.trigger, &session_id);
107
108 tracing::info!(
109 target: "bashwrap",
110 trigger = args.trigger.as_str(),
111 session_id = %session_id,
112 block_id = %block_id.as_deref().unwrap_or(""),
113 "precompact: publishing compaction_started"
114 );
115
116 if let Err(e) = client
117 .publish_compaction_started(block_id.as_deref(), &payload)
118 .await
119 {
120 tracing::warn!(target: "bashwrap", error = %e, "precompact: publish failed");
123 }
124
125 Ok(())
126}
127
128fn parse_session_id(stdin_payload: &str) -> String {
134 serde_json::from_str::<PreCompactInput>(stdin_payload)
135 .map(|i| i.session_id)
136 .unwrap_or_default()
137}
138
139fn build_payload(trigger: Trigger, session_id: &str) -> CompactionStarted {
141 CompactionStarted {
142 trigger: match trigger {
143 Trigger::Manual => CompactionTrigger::Manual,
144 Trigger::Auto => CompactionTrigger::Auto,
145 },
146 session_id: session_id.to_string(),
147 started_at: chrono::Utc::now().to_rfc3339(),
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use clap::Parser;
155 use crate::test_env_lock::ENV_LOCK;
156
157 fn clear_env() {
162 std::env::remove_var("AGENTMUX_LOCAL_URL");
163 std::env::remove_var("AGENTMUX_AUTH_KEY");
164 std::env::remove_var("AGENTMUX_BLOCKID");
165 }
166
167 #[derive(clap::Parser)]
170 struct TestCli {
171 #[command(flatten)]
172 args: Args,
173 }
174
175 #[test]
176 fn parses_trigger_manual() {
177 let cli = TestCli::parse_from(["precompact", "--trigger=manual"]);
178 assert_eq!(cli.args.trigger, Trigger::Manual);
179 }
180
181 #[test]
182 fn parses_trigger_auto() {
183 let cli = TestCli::parse_from(["precompact", "--trigger=auto"]);
184 assert_eq!(cli.args.trigger, Trigger::Auto);
185 }
186
187 #[test]
188 fn rejects_unknown_trigger_value() {
189 let result = TestCli::try_parse_from(["precompact", "--trigger=sometimes"]);
190 assert!(result.is_err());
191 }
192
193 #[test]
194 fn rejects_missing_trigger() {
195 let result = TestCli::try_parse_from(["precompact"]);
196 assert!(result.is_err());
197 }
198
199 #[test]
202 fn parses_session_id_from_full_precompact_payload() {
203 let payload = serde_json::json!({
206 "session_id": "sess-abc123",
207 "transcript_path": "/tmp/sess-abc123.jsonl",
208 "cwd": "/home/user/project",
209 "permission_mode": "default",
210 "hook_event_name": "PreCompact"
211 })
212 .to_string();
213 assert_eq!(parse_session_id(&payload), "sess-abc123");
214 }
215
216 #[test]
217 fn malformed_stdin_yields_empty_session_id() {
218 assert_eq!(parse_session_id("not json"), "");
219 assert_eq!(parse_session_id(""), "");
220 }
221
222 #[test]
223 fn missing_session_id_field_yields_empty_string() {
224 assert_eq!(parse_session_id(r#"{"cwd":"/tmp"}"#), "");
225 }
226
227 #[test]
230 fn build_payload_manual_shape() {
231 let payload = build_payload(Trigger::Manual, "sess-1");
232 let v = serde_json::to_value(&payload).unwrap();
233 assert_eq!(v["trigger"], "manual");
234 assert_eq!(v["sessionId"], "sess-1");
235 assert!(v["startedAt"].as_str().unwrap().contains('T'), "expects RFC3339");
236 }
237
238 #[test]
239 fn build_payload_auto_shape() {
240 let payload = build_payload(Trigger::Auto, "sess-2");
241 let v = serde_json::to_value(&payload).unwrap();
242 assert_eq!(v["trigger"], "auto");
243 assert_eq!(v["sessionId"], "sess-2");
244 }
245
246 #[test]
247 fn build_payload_camel_cases_wire_fields() {
248 let payload = build_payload(Trigger::Manual, "");
249 let v = serde_json::to_value(&payload).unwrap();
250 let obj = v.as_object().unwrap();
251 assert!(obj.contains_key("sessionId"));
252 assert!(obj.contains_key("startedAt"));
253 assert!(!obj.contains_key("session_id"), "must be camelCase on the wire");
254 }
255
256 #[test]
259 fn from_env_none_without_wps_env() {
260 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
261 clear_env();
262 assert!(WpsClient::from_env().is_none());
265 clear_env();
266 }
267
268 #[test]
269 fn block_id_falls_back_to_env_when_unset() {
270 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
271 clear_env();
272 std::env::set_var("AGENTMUX_BLOCKID", "block-42");
273 let block_id = std::env::var("AGENTMUX_BLOCKID")
274 .ok()
275 .filter(|v| !v.is_empty());
276 assert_eq!(block_id.as_deref(), Some("block-42"));
277 clear_env();
278 }
279
280 #[test]
281 fn block_id_empty_env_treated_as_absent() {
282 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
283 clear_env();
284 std::env::set_var("AGENTMUX_BLOCKID", "");
285 let block_id = std::env::var("AGENTMUX_BLOCKID")
286 .ok()
287 .filter(|v| !v.is_empty());
288 assert_eq!(block_id, None);
289 clear_env();
290 }
291}