1use std::sync::atomic::Ordering;
2
3use super::server::{CepComputedStats, CrpMode, LeanCtxServer, ToolCallRecord};
4use super::startup::auto_consolidate_knowledge;
5use super::{ctx_compress, ctx_share};
6
7fn ocla_metric_points(
13 context: &crate::core::ocla::OclaRequestContext,
14 tool: &str,
15 original: usize,
16 saved: usize,
17 duration_ms: u64,
18) -> Vec<crate::core::ocla::MetricPoint> {
19 let mut dimensions = std::collections::BTreeMap::new();
20 dimensions.insert("tool".to_string(), tool.to_string());
21 let original = metric_milli(original as u64);
22 let saved = metric_milli(saved as u64);
23 let duration = metric_milli(duration_ms);
24 vec![
25 crate::core::ocla::MetricPoint {
26 context: context.clone(),
27 name: "mcp.tool.original_tokens".to_string(),
28 value_milli: original,
29 dimensions: dimensions.clone(),
30 },
31 crate::core::ocla::MetricPoint {
32 context: context.clone(),
33 name: "mcp.tool.saved_tokens".to_string(),
34 value_milli: saved,
35 dimensions: dimensions.clone(),
36 },
37 crate::core::ocla::MetricPoint {
38 context: context.clone(),
39 name: "mcp.tool.duration_ms".to_string(),
40 value_milli: duration,
41 dimensions,
42 },
43 ]
44}
45
46fn metric_milli(value: u64) -> i64 {
47 i64::try_from(value.saturating_mul(1_000)).unwrap_or(i64::MAX)
48}
49
50impl LeanCtxServer {
51 pub async fn record_call(
53 &self,
54 tool: &str,
55 original: usize,
56 saved: usize,
57 mode: Option<String>,
58 ) {
59 self.record_call_with_timing(tool, original, saved, mode, 0)
60 .await;
61 }
62
63 pub async fn record_call_with_path(
67 &self,
68 tool: &str,
69 original: usize,
70 saved: usize,
71 mode: Option<String>,
72 path: Option<&str>,
73 duration_ms: u64,
74 ) {
75 self.record_call_with_timing_inner(tool, original, saved, mode, duration_ms, path)
76 .await;
77 }
78
79 pub async fn record_call_with_timing(
81 &self,
82 tool: &str,
83 original: usize,
84 saved: usize,
85 mode: Option<String>,
86 duration_ms: u64,
87 ) {
88 self.record_call_with_timing_inner(tool, original, saved, mode, duration_ms, None)
89 .await;
90 }
91
92 async fn record_call_with_timing_inner(
93 &self,
94 tool: &str,
95 original: usize,
96 saved: usize,
97 mode: Option<String>,
98 duration_ms: u64,
99 path: Option<&str>,
100 ) {
101 if let Some(agent_id) = self.presence_agent_id.read().await.clone()
102 && let Err(error) = crate::core::agents::AgentRegistry::heartbeat_persistent(&agent_id)
103 {
104 tracing::warn!("lean-ctx: failed to update MCP agent heartbeat: {error}");
105 }
106 let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
107 let mut calls = self.tool_calls.write().await;
108 calls.push(ToolCallRecord {
109 tool: tool.to_string(),
110 original_tokens: original,
111 saved_tokens: saved,
112 mode: mode.clone(),
113 duration_ms,
114 timestamp: ts.clone(),
115 });
116
117 const MAX_TOOL_CALL_RECORDS: usize = 500;
118 if calls.len() > MAX_TOOL_CALL_RECORDS {
119 let excess = calls.len() - MAX_TOOL_CALL_RECORDS;
120 calls.drain(..excess);
121 }
122
123 if duration_ms > 0 || original > 0 {
128 Self::append_tool_call_log(tool, duration_ms, original, saved, mode.as_deref(), &ts);
129 }
130
131 crate::core::events::emit_tool_call(
132 tool,
133 original as u64,
134 saved as u64,
135 mode.clone(),
136 duration_ms,
137 path.map(ToString::to_string),
138 );
139
140 let output_tokens = original.saturating_sub(saved);
141 crate::core::stats::record_at_turn(
142 tool,
143 original,
144 output_tokens,
145 crate::core::context_overhead::observed_turns(),
146 );
147 if tool == "ctx_shell" {
154 crate::core::savings_ledger::record_tool_event(
155 tool,
156 original,
157 output_tokens,
158 None,
159 None,
160 );
161 }
162
163 {
166 let session_r = self.session.read().await;
167 let agent_id = self
168 .presence_agent_id
169 .read()
170 .await
171 .clone()
172 .unwrap_or_default();
173 let ctx = crate::core::ocla::OclaRequestContext {
174 request_id: format!("mcp-{}", self.call_count.load(Ordering::Relaxed)),
175 session_id: session_r.id.clone(),
176 agent_id,
177 content_ref: path.map_or_else(|| format!("tool:{tool}"), |p| format!("file:{p}")),
178 tenant_id: None,
179 trace_id: "tr-unit".into(),
180 };
181 drop(session_r);
182 let observation = crate::core::ocla::Observation {
183 context: ctx.clone(),
184 name: format!("tool_call:{tool}"),
185 attributes: std::collections::BTreeMap::from([
186 ("original_tokens".into(), original.to_string()),
187 ("saved_tokens".into(), saved.to_string()),
188 ("duration_ms".into(), duration_ms.to_string()),
189 ]),
190 };
191 let hook = crate::core::ocla::OclaRegistry::global()
192 .observation_hook
193 .as_ref();
194 let _ = hook.observe(observation).await;
195
196 let outcome = crate::core::ocla::Outcome {
197 context: ctx.clone(),
198 accepted: Some(saved > 0),
199 quality_score_milli: if original > 0 {
200 Some(((saved as u64 * 1000) / original as u64).min(1000) as u16)
201 } else {
202 None
203 },
204 outcome_ref: None,
205 };
206 let _ = crate::core::ocla::OclaRegistry::global()
207 .outcome_tracker
208 .record_outcome(outcome);
209
210 let metrics = ocla_metric_points(&ctx, tool, original, saved, duration_ms);
214 let _ = crate::core::ocla::OclaRegistry::global()
215 .metrics_exporter
216 .export_metrics(metrics)
217 .await;
218 }
219 let mut session = self.session.write().await;
220 session.record_tool_call(saved as u64, original as u64);
221 if tool == "ctx_shell" {
222 session.record_command();
223 }
224 let pending_save = if session.should_save() {
225 session.prepare_save().ok()
226 } else {
227 None
228 };
229 drop(calls);
230 drop(session);
231
232 if let Some(prepared) = pending_save {
233 tokio::task::spawn_blocking(move || {
234 let _ = prepared.write_to_disk();
235 });
236 }
237
238 self.write_mcp_live_stats().await;
239 write_science_live_stats();
240 }
241
242 pub fn increment_and_check(&self) -> bool {
244 let count = self.call_count.fetch_add(1, Ordering::Relaxed) + 1;
245 let interval = Self::checkpoint_interval_effective();
246 interval > 0 && count.is_multiple_of(interval)
247 }
248
249 pub async fn auto_checkpoint(&self) -> Option<String> {
251 let cache = self.cache.read().await;
252 if cache.get_all_entries().is_empty() {
253 return None;
254 }
255 let complexity = crate::core::adaptive::classify_from_context(&cache);
256 let checkpoint = ctx_compress::handle(&cache, false, CrpMode::effective());
257 drop(cache);
258
259 let mut session = self.session.write().await;
260 let _ = session.save();
261 let session_summary = session.format_compact();
262 let has_insights = !session.findings.is_empty() || !session.decisions.is_empty();
263 let project_root = session.project_root.clone();
264 let summary_candidate = crate::core::session_summary::build_candidate(&session);
266 drop(session);
267
268 if has_insights && let Some(ref root) = project_root {
269 let root = root.clone();
270 std::thread::spawn(move || {
271 auto_consolidate_knowledge(&root);
272 });
273 }
274
275 if let Some(ref root) = project_root {
277 let root = root.clone();
278 std::thread::spawn(move || {
279 let _ =
280 crate::core::session_summary::maybe_record_periodic(&root, summary_candidate);
281 });
282 }
283
284 let multi_agent_block = self
285 .auto_multi_agent_checkpoint(project_root.as_ref())
286 .await;
287
288 self.record_call("ctx_compress", 0, 0, Some("auto".to_string()))
289 .await;
290
291 self.record_cep_snapshot().await;
292
293 if !crate::core::protocol::meta_visible() {
294 return None;
295 }
296
297 let doc_reminder = {
298 let session = self.session.read().await;
299 let calls = self.tool_calls.read().await;
300 Self::activity_nudge(&session, &calls)
301 };
302
303 Some(format!(
304 "{checkpoint}\n\n--- SESSION STATE ---\n{session_summary}\n\n{}{multi_agent_block}{doc_reminder}",
305 complexity.instruction_suffix()
306 ))
307 }
308
309 async fn auto_multi_agent_checkpoint(&self, project_root: Option<&String>) -> String {
310 let Some(root) = project_root else {
311 return String::new();
312 };
313
314 let registry = crate::core::agents::AgentRegistry::load_or_create();
315 let active = registry.list_active(Some(root));
316 if active.len() <= 1 {
317 return String::new();
318 }
319
320 let agent_id = self.agent_id.read().await;
321 let my_id = match agent_id.as_deref() {
322 Some(id) => id.to_string(),
323 None => return String::new(),
324 };
325 drop(agent_id);
326
327 let cache = self.cache.read().await;
328 let entries = cache.get_all_entries();
329 if !entries.is_empty() {
330 let mut by_access: Vec<_> = entries.iter().collect();
331 by_access.sort_by_key(|x| std::cmp::Reverse(x.1.read_count()));
332 let top_paths: Vec<&str> = by_access
333 .iter()
334 .take(5)
335 .map(|(key, _)| key.as_str())
336 .collect();
337 let paths_csv = top_paths.join(",");
338
339 let _ = ctx_share::handle(
340 "push",
341 Some(&my_id),
342 None,
343 Some(&paths_csv),
344 None,
345 &cache,
346 root,
347 );
348 }
349 drop(cache);
350
351 let pending_count = registry
352 .scratchpad
353 .iter()
354 .filter(|e| !e.read_by.contains(&my_id) && e.from_agent != my_id)
355 .count();
356
357 let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
358 .unwrap_or_default()
359 .join("agents")
360 .join("shared");
361 let shared_count = if shared_dir.exists() {
362 std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
363 } else {
364 0
365 };
366
367 let agent_names: Vec<String> = active
368 .iter()
369 .map(|a| {
370 let role = a.role.as_deref().unwrap_or(&a.agent_type);
371 format!("{role}({})", &a.agent_id[..8.min(a.agent_id.len())])
372 })
373 .collect();
374
375 format!(
376 "\n\n--- MULTI-AGENT SYNC ---\nAgents: {} | Pending msgs: {} | Shared contexts: {}\nAuto-shared top-5 cached files.\n--- END SYNC ---",
377 agent_names.join(", "),
378 pending_count,
379 shared_count,
380 )
381 }
382
383 pub fn append_tool_call_log(
385 tool: &str,
386 duration_ms: u64,
387 original: usize,
388 saved: usize,
389 mode: Option<&str>,
390 timestamp: &str,
391 ) {
392 const MAX_LOG_LINES: usize = 50;
393 if let Ok(dir) = crate::core::paths::state_dir() {
394 let log_path = dir.join("tool-calls.log");
395 let mode_str = mode.unwrap_or("-");
396 let slow = if duration_ms > 5000 { " **SLOW**" } else { "" };
397 let line = format!(
398 "{timestamp}\t{tool}\t{duration_ms}ms\torig={original}\tsaved={saved}\tmode={mode_str}{slow}\n"
399 );
400
401 let mut lines: Vec<String> = std::fs::read_to_string(&log_path)
402 .unwrap_or_default()
403 .lines()
404 .map(std::string::ToString::to_string)
405 .collect();
406
407 lines.push(line.trim_end().to_string());
408 if lines.len() > MAX_LOG_LINES {
409 lines.drain(0..lines.len() - MAX_LOG_LINES);
410 }
411
412 let _ = std::fs::write(&log_path, lines.join("\n") + "\n");
413 }
414 }
415
416 fn compute_cep_stats(
417 calls: &[ToolCallRecord],
418 stats: &crate::core::cache::CacheStats,
419 complexity: &crate::core::adaptive::TaskComplexity,
420 ) -> CepComputedStats {
421 let total_original: u64 = calls.iter().map(|c| c.original_tokens as u64).sum();
422 let total_saved: u64 = calls.iter().map(|c| c.saved_tokens as u64).sum();
423 let total_compressed = total_original.saturating_sub(total_saved);
424 let compression_rate = if total_original > 0 {
425 total_saved as f64 / total_original as f64
426 } else {
427 0.0
428 };
429
430 let modes_used: std::collections::HashSet<&str> =
431 calls.iter().filter_map(|c| c.mode.as_deref()).collect();
432 let mode_diversity = (modes_used.len() as f64 / 10.0).min(1.0);
433 let cache_util = stats.hit_rate() / 100.0;
434 let output_efficiency = 1.0 - crate::core::output_echo::current_avg_ratio();
437 let cep_score = cache_util * 0.25
438 + mode_diversity * 0.15
439 + compression_rate * 0.45
440 + output_efficiency * 0.15;
441
442 let mut mode_counts: std::collections::HashMap<String, u64> =
443 std::collections::HashMap::new();
444 for call in calls {
445 if let Some(ref mode) = call.mode {
446 *mode_counts.entry(mode.clone()).or_insert(0) += 1;
447 }
448 }
449
450 CepComputedStats {
451 cep_score: (cep_score * 100.0).round() as u32,
452 cache_util: (cache_util * 100.0).round() as u32,
453 mode_diversity: (mode_diversity * 100.0).round() as u32,
454 compression_rate: (compression_rate * 100.0).round() as u32,
455 total_original,
456 total_compressed,
457 total_saved,
458 mode_counts,
459 complexity: format!("{complexity:?}"),
460 cache_hits: stats.cache_hits(),
461 total_reads: stats.total_reads(),
462 tool_call_count: calls.len() as u64,
463 }
464 }
465
466 async fn write_mcp_live_stats(&self) {
467 let count = self.call_count.load(Ordering::Relaxed);
468 if count > 1 && !count.is_multiple_of(5) {
469 return;
470 }
471
472 let cache = self.cache.read().await;
473 let calls = self.tool_calls.read().await;
474 let stats = cache.get_stats();
475 let complexity = crate::core::adaptive::classify_from_context(&cache);
476 let dedup = crate::tools::ctx_read::dedup_hook::summary();
477
478 let cs = Self::compute_cep_stats(&calls, stats, &complexity);
479 let started_at = calls
480 .first()
481 .map(|c| c.timestamp.clone())
482 .unwrap_or_default();
483
484 drop(cache);
485 drop(calls);
486
487 crate::core::stats::record_cep_session(
493 cs.cep_score,
494 cs.cache_hits,
495 cs.total_reads,
496 cs.total_original,
497 cs.total_compressed,
498 &cs.mode_counts,
499 cs.tool_call_count,
500 &cs.complexity,
501 );
502
503 let effective_hits = cs.cache_hits + dedup.dedup_hits as u64;
504 let effective_reads = std::cmp::max(cs.total_reads, dedup.total_reads as u64);
505
506 let source_snapshot = crate::core::auto_mode_resolver::source_counts();
507 let compressed_cache_hits = source_snapshot
508 .iter()
509 .find(|(k, _)| *k == "compressed_cache_hit")
510 .map_or(0, |(_, v)| *v);
511 let full_delivery_degraded = source_snapshot
512 .iter()
513 .find(|(k, _)| *k == "full_delivery_degraded")
514 .map_or(0, |(_, v)| *v);
515
516 let live = serde_json::json!({
517 "cep_score": cs.cep_score,
518 "cache_utilization": cs.cache_util,
519 "mode_diversity": cs.mode_diversity,
520 "compression_rate": cs.compression_rate,
521 "task_complexity": cs.complexity,
522 "files_cached": cs.total_reads,
523 "total_reads": cs.total_reads,
524 "cache_hits": cs.cache_hits,
525 "dedup_reads": dedup.total_reads,
526 "dedup_hits": dedup.dedup_hits,
527 "dedup_tokens_saved": dedup.tokens_saved,
528 "effective_cache_hits": effective_hits,
529 "effective_cache_reads": effective_reads,
530 "compressed_cache_hits": compressed_cache_hits,
531 "full_delivery_degraded": full_delivery_degraded,
532 "tokens_saved": cs.total_saved,
533 "tokens_original": cs.total_original,
534 "tool_calls": cs.tool_call_count,
535 "started_at": started_at,
536 "updated_at": chrono::Local::now().to_rfc3339(),
537 });
538
539 if let Ok(dir) = crate::core::paths::state_dir() {
540 let _ = std::fs::write(dir.join("mcp-live.json"), live.to_string());
541 }
542 }
543
544 pub async fn record_cep_snapshot(&self) {
546 let cache = self.cache.read().await;
547 let calls = self.tool_calls.read().await;
548 let stats = cache.get_stats();
549 let complexity = crate::core::adaptive::classify_from_context(&cache);
550
551 let cs = Self::compute_cep_stats(&calls, stats, &complexity);
552
553 drop(cache);
554 drop(calls);
555
556 crate::core::stats::record_cep_session(
557 cs.cep_score,
558 cs.cache_hits,
559 cs.total_reads,
560 cs.total_original,
561 cs.total_compressed,
562 &cs.mode_counts,
563 cs.tool_call_count,
564 &cs.complexity,
565 );
566 }
567
568 fn activity_nudge(
569 session: &crate::core::session::SessionState,
570 calls: &[ToolCallRecord],
571 ) -> &'static str {
572 let last_doc_ts = session
573 .progress
574 .last()
575 .map(|p| p.timestamp)
576 .or_else(|| session.decisions.last().map(|d| d.timestamp))
577 .or_else(|| session.findings.last().map(|f| f.timestamp));
578
579 if let Some(ts) = last_doc_ts {
580 let age = chrono::Utc::now() - ts;
581 if age.num_minutes() < 8 {
582 return "";
583 }
584 }
585
586 let (weighted_score, significant_tools, shell_heavy, edit_heavy) =
587 Self::compute_activity_score(calls, last_doc_ts);
588
589 if weighted_score < 20 || significant_tools < 5 {
590 if session.stats.total_tool_calls >= 30
591 && session.decisions.is_empty()
592 && session.progress.is_empty()
593 {
594 return "\n[CHECKPOINT: please document current progress via ctx_session(action=\"task\") or ctx_knowledge(action=\"remember\")]";
595 }
596 return "";
597 }
598
599 if shell_heavy {
600 "\n[CHECKPOINT: multiple shell commands executed — any test results or findings worth persisting via ctx_knowledge(action=\"remember\")?]"
601 } else if edit_heavy {
602 "\n[CHECKPOINT: several files modified — document the architecture decision or pattern via ctx_knowledge(action=\"remember\")?]"
603 } else {
604 "\n[CHECKPOINT: significant work detected — consider persisting decisions via ctx_knowledge(action=\"remember\")]"
605 }
606 }
607
608 fn compute_activity_score(
609 calls: &[ToolCallRecord],
610 last_doc_ts: Option<chrono::DateTime<chrono::Utc>>,
611 ) -> (u32, u32, bool, bool) {
612 let mut weighted_score: u32 = 0;
613 let mut significant_tools: u32 = 0;
614 let mut shell_count: u32 = 0;
615 let mut edit_count: u32 = 0;
616
617 let since_doc: Vec<&ToolCallRecord> = if let Some(ts) = last_doc_ts {
618 let ts_str = ts.format("%Y-%m-%d %H:%M:%S").to_string();
619 calls.iter().filter(|c| c.timestamp > ts_str).collect()
620 } else {
621 calls.iter().collect()
622 };
623
624 for call in &since_doc {
625 let tool = call.tool.as_str();
626 let is_knowledge = tool == "ctx_knowledge" || tool == "ctx_session";
627 if is_knowledge {
628 weighted_score = 0;
629 significant_tools = 0;
630 shell_count = 0;
631 edit_count = 0;
632 continue;
633 }
634
635 let (weight, significant) = match tool {
636 "edit" | "write" | "str_replace" => {
637 edit_count += 1;
638 (4u32, true)
639 }
640 "ctx_shell" => {
641 shell_count += 1;
642 let is_test_or_build = call
643 .mode
644 .as_deref()
645 .is_some_and(|m| m.contains("test") || m.contains("build"));
646 if is_test_or_build {
647 (3, true)
648 } else {
649 (2, true)
650 }
651 }
652 "ctx_read" => {
653 let is_cache_hit = call.saved_tokens > 0
654 && call.original_tokens > 0
655 && call.saved_tokens == call.original_tokens;
656 if is_cache_hit { (0, false) } else { (1, false) }
657 }
658 _ => (1, false),
659 };
660
661 weighted_score = weighted_score.saturating_add(weight);
662 if significant {
663 significant_tools += 1;
664 }
665 }
666
667 let shell_heavy = shell_count >= 3 && shell_count > edit_count;
668 let edit_heavy = edit_count >= 3 && edit_count >= shell_count;
669
670 (weighted_score, significant_tools, shell_heavy, edit_heavy)
671 }
672}
673
674pub(crate) fn write_science_live_stats() {
676 use crate::core::anti_interrupt::{InterruptionEvent, session_interruptions};
677 use crate::core::cognitive_gate;
678
679 let events = session_interruptions();
680 let mut redundant_reads = 0usize;
681 let mut bounce_waste = 0usize;
682 let mut prevented = 0usize;
683 for (event, was_prevented) in &events {
684 if *was_prevented {
685 prevented += 1;
686 }
687 match event {
688 InterruptionEvent::RedundantRead { .. } => redundant_reads += 1,
689 InterruptionEvent::BounceWaste { .. } => bounce_waste += 1,
690 _ => {}
691 }
692 }
693
694 let echo = crate::core::output_echo::load_stats();
695 let impact = crate::core::anti_interrupt::compute_impact();
696
697 let stats = serde_json::json!({
698 "cognitive_mode": if cognitive_gate::full_science_enabled() {
699 "full"
700 } else if cognitive_gate::basic_science_enabled() {
701 "basic"
702 } else {
703 "off"
704 },
705 "anti_interrupt": {
706 "events": events.len(),
707 "redundant_reads": redundant_reads,
708 "bounce_waste": bounce_waste,
709 "prevented": prevented,
710 "score": impact.score,
711 "focus_time_saved_minutes": impact.focus_time_saved_minutes,
712 },
713 "verbosity": {
714 "recommended_level": crate::core::verbosity::recommended_compression()
715 .map(|l| format!("{l:?}")),
716 "auto_applied": crate::core::verbosity::auto_apply_happened(),
717 },
718 "prefetch": {
719 "warmed": crate::core::context_prefetch::warmed_count(),
720 "skipped": crate::core::context_prefetch::skipped_count(),
721 },
722 "echo": {
723 "avg_ratio": echo.avg_ratio(50),
724 "window": echo.reports.len(),
725 "total_analyzed": echo.total_analyzed,
726 },
727 "updated_at": chrono::Local::now().to_rfc3339(),
728 });
729
730 if let Ok(dir) = crate::core::paths::state_dir() {
731 if let Ok(json) = serde_json::to_string_pretty(&stats) {
732 let _ = std::fs::write(dir.join("science-live.json"), json);
733 }
734 }
735}
736
737#[cfg(test)]
738mod activity_score_tests {
739 use super::*;
740
741 fn make_call(tool: &str, mode: Option<&str>) -> ToolCallRecord {
742 ToolCallRecord {
743 tool: tool.to_string(),
744 original_tokens: 100,
745 saved_tokens: 50,
746 mode: mode.map(String::from),
747 duration_ms: 10,
748 timestamp: "2026-01-01 12:00:00".to_string(),
749 }
750 }
751
752 fn make_cache_hit() -> ToolCallRecord {
753 ToolCallRecord {
754 tool: "ctx_read".to_string(),
755 original_tokens: 100,
756 saved_tokens: 100,
757 mode: Some("full".to_string()),
758 duration_ms: 1,
759 timestamp: "2026-01-01 12:00:00".to_string(),
760 }
761 }
762
763 #[test]
764 fn empty_calls_zero_score() {
765 let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&[], None);
766 assert_eq!(score, 0);
767 assert_eq!(sig, 0);
768 }
769
770 #[test]
771 fn edits_have_highest_weight() {
772 let calls = vec![
773 make_call("edit", None),
774 make_call("edit", None),
775 make_call("edit", None),
776 ];
777 let (score, sig, _, edit_heavy) = LeanCtxServer::compute_activity_score(&calls, None);
778 assert_eq!(score, 12);
779 assert_eq!(sig, 3);
780 assert!(edit_heavy);
781 }
782
783 #[test]
784 fn shell_test_build_weight_three() {
785 let calls = vec![
786 make_call("ctx_shell", Some("test")),
787 make_call("ctx_shell", Some("build")),
788 make_call("ctx_shell", Some("test")),
789 ];
790 let (score, sig, shell_heavy, _) = LeanCtxServer::compute_activity_score(&calls, None);
791 assert_eq!(score, 9);
792 assert_eq!(sig, 3);
793 assert!(shell_heavy);
794 }
795
796 #[test]
797 fn cache_hits_zero_weight() {
798 let calls = vec![make_cache_hit(), make_cache_hit(), make_cache_hit()];
799 let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
800 assert_eq!(score, 0);
801 assert_eq!(sig, 0);
802 }
803
804 #[test]
805 fn knowledge_call_resets_score() {
806 let calls = vec![
807 make_call("edit", None),
808 make_call("edit", None),
809 make_call("ctx_knowledge", None),
810 make_call("ctx_read", None),
811 ];
812 let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
813 assert_eq!(score, 1);
814 assert_eq!(sig, 0);
815 }
816
817 #[test]
818 fn mixed_workflow_scoring() {
819 let calls = vec![
820 make_call("ctx_read", None),
821 make_call("ctx_read", None),
822 make_call("edit", None),
823 make_call("edit", None),
824 make_call("ctx_shell", Some("test output")),
825 make_call("ctx_shell", None),
826 ];
827 let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
828 assert_eq!(score, 2 + 4 + 4 + 3 + 2);
829 assert_eq!(sig, 4);
830 }
831
832 #[test]
833 fn ocla_metrics_are_payload_free_and_saturating() {
834 let context = crate::core::ocla::OclaRequestContext {
835 request_id: "mcp-1".into(),
836 session_id: "session-1".into(),
837 agent_id: "agent-1".into(),
838 content_ref: "tool:ctx_read".into(),
839 tenant_id: None,
840 trace_id: "tr-unit".into(),
841 };
842 let points = super::ocla_metric_points(&context, "ctx_read", 750, 125, 42);
843 assert_eq!(points.len(), 3);
844 assert_eq!(points[0].name, "mcp.tool.original_tokens");
845 assert_eq!(points[0].value_milli, 750_000);
846 assert_eq!(points[1].name, "mcp.tool.saved_tokens");
847 assert_eq!(points[1].value_milli, 125_000);
848 assert_eq!(points[2].name, "mcp.tool.duration_ms");
849 assert_eq!(points[2].value_milli, 42_000);
850 assert_eq!(points[0].dimensions["tool"], "ctx_read");
851 assert_eq!(points[0].context, context);
852 assert!(!points[0].dimensions.contains_key("path"));
853
854 let saturated = super::ocla_metric_points(
855 &points[0].context,
856 "ctx_shell",
857 usize::MAX,
858 usize::MAX,
859 u64::MAX,
860 );
861 assert!(saturated.iter().all(|point| point.value_milli == i64::MAX));
862 }
863}