use std::collections::HashMap;
use pretty_assertions::assert_eq;
use rho_sdk::{
model::{
ContentBlock, ModelEvent, ModelIdentity, ModelResponse, ModelUsage, ToolCall, ToolSpec,
},
provider::{ScriptedProvider, ScriptedTurn},
tool::{ScriptedTool, ScriptedToolOutcome, ToolOutput},
Rho, SessionOptions, SystemPrompt,
};
use super::{largest_remainder, ContextBasis, ContextReport};
use crate::prompt::{PromptSource, PromptSourceKind};
fn spec(name: &str) -> ToolSpec {
ToolSpec {
name: name.into(),
description: format!("{name} tool"),
input_schema: serde_json::json!({"type": "object"}),
}
}
async fn calibrated_session(reported: u64) -> rho_sdk::Session {
let provider = ScriptedProvider::new(
ModelIdentity::new("test", "test", "test"),
[
ScriptedTurn::completed(ModelResponse::Assistant(vec![ContentBlock::ToolCall(
ToolCall {
id: "read-1".into(),
name: "read_file".into(),
arguments: serde_json::json!({"path": "src/main.rs"}),
},
)])),
ScriptedTurn::streaming(
vec![ModelEvent::Usage(ModelUsage {
input_tokens: Some(reported),
..ModelUsage::default()
})],
ModelResponse::Assistant(vec![ContentBlock::Text("done".into())]),
),
],
);
let runtime = Rho::builder()
.provider(provider)
.system_prompt(SystemPrompt::Custom("x".repeat(4_000)))
.tool(ScriptedTool::new(
spec("read_file"),
ScriptedToolOutcome::Success(ToolOutput::text("y".repeat(2_000))),
))
.tool(ScriptedTool::new(
spec("mcp__mem__query"),
ScriptedToolOutcome::Success(ToolOutput::text("unused")),
))
.build()
.unwrap();
let session = runtime.session(SessionOptions::new()).await.unwrap();
session.complete("read main").await.unwrap();
session
}
#[tokio::test]
async fn report_groups_sources_and_scales_rows_to_calibrated_total() {
let session = calibrated_session(100_000).await;
let sources = [
PromptSource {
kind: PromptSourceKind::Base,
path: None,
bytes: 3_000,
},
PromptSource {
kind: PromptSourceKind::Agents,
path: Some("/repo/AGENTS.md".into()),
bytes: 1_000,
},
];
let mcp_tools = HashMap::from([("mcp__mem__query".to_string(), "mem".to_string())]);
let report = ContextReport::new(
&session.context_breakdown(),
&sources,
&mcp_tools,
Some(200_000),
);
let layout: Vec<_> = report
.groups
.iter()
.map(|group| {
let rows: Vec<_> = group
.rows
.iter()
.map(|row| (row.label.as_str(), row.detail.as_deref()))
.collect();
(group.label, rows)
})
.collect();
assert_eq!(
layout,
vec![
(
"System prompt",
vec![
("Rho instructions", None),
("Instruction file", Some("/repo/AGENTS.md")),
]
),
(
"Tool schemas",
vec![
("MCP mem", Some("1 schema")),
("Built-in tools", Some("1 schema")),
]
),
(
"Messages",
vec![
("Assistant (text, reasoning, tool calls)", None),
("User and host notes", None),
]
),
("Tool results", vec![("read_file", None)]),
]
);
assert_eq!(report.basis, ContextBasis::ProviderCalibrated);
assert_eq!(report.tokens, session.context_estimate().tokens());
let system = &report.groups[0];
let [base, agents] = [&system.rows[0], &system.rows[1]];
assert!(base.tokens.abs_diff(3 * agents.tokens) <= 3, "{system:?}");
let shown: u64 = report.groups.iter().map(|group| group.tokens).sum();
assert!(
report.tokens - shown < report.tokens / 100,
"{shown} of {}",
report.tokens
);
}
#[test]
fn largest_remainder_distributes_leftovers_by_fraction() {
struct Case {
name: &'static str,
total: u64,
weights: &'static [u64],
expected: Option<&'static [u64]>,
}
let cases = [
Case {
name: "exact split",
total: 8,
weights: &[3, 1],
expected: Some(&[6, 2]),
},
Case {
name: "leftovers follow fractions",
total: 8,
weights: &[5, 3, 2],
expected: Some(&[4, 2, 2]),
},
Case {
name: "fewer tokens than sources",
total: 2,
weights: &[1, 1, 1],
expected: Some(&[1, 1, 0]),
},
Case {
name: "zero weight gets nothing",
total: 5,
weights: &[0, 1],
expected: Some(&[0, 5]),
},
Case {
name: "no weight to split by",
total: 5,
weights: &[0, 0],
expected: None,
},
];
for case in cases {
assert_eq!(
largest_remainder(case.total, case.weights).as_deref(),
case.expected,
"{}",
case.name
);
}
}