use pretty_assertions::assert_eq;
use rho_sdk::{
model::{ModelIdentity, ToolCall, ToolResult},
CompactionState, InMemorySessionStore, Revision, SessionId,
};
use serde_json::json;
use super::*;
fn list(content: &str) -> TodoList {
TodoList::parse(json!({"todos": [{"content": content, "status": "pending"}]})).unwrap()
}
fn snapshot(history: Vec<Message>) -> SessionSnapshot {
SessionSnapshot::new(
SessionId::new(),
Revision::default(),
history,
ModelIdentity::new("test", "test", "test"),
CompactionState::default(),
)
}
#[test]
fn oversized_checklists_are_rejected_or_suspended_without_losing_state() {
use rho_sdk::RequestContext;
let state = TodoState::default();
let original = list("original");
let oversized = list(&"long task ".repeat(10_000));
let prompt = vec![
Message::System("system prompt".into()),
Message::user_text("actual user instruction must also fit"),
];
let recovery_tokens =
estimate_messages_tokens(&[Message::model_context("recovery notice ".repeat(100))]);
let overhead = estimate_context_tokens(&prompt, &[]);
let limit = overhead + recovery_tokens;
state.set_context_window(Some(limit));
let writer = TodoState::default();
writer.replace(Some(oversized.clone()));
state.restore(&writer.decorate(snapshot(prompt.clone())));
assert_eq!(state.messages(&SessionId::new()), Vec::<Message>::new());
let context = ContextEstimate::from_estimated_tokens(overhead);
state.set_context_budget(Some(limit), context, &prompt, &[]);
state.try_replace(original.clone()).unwrap();
let asked = overhead + estimate_messages_tokens(&context_messages(Some(&oversized)));
let error = state.try_replace(oversized.clone()).unwrap_err();
assert_eq!((error.kind(), error.message()), (ToolErrorKind::InvalidArguments, format!("todo mandatory context budget exceeded: limit {limit} estimated tokens, asked {asked}; shorten or clear the checklist").as_str()));
assert_eq!(state.list(), Some(original));
state.restore(&writer.decorate(snapshot(prompt.clone())));
assert_eq!(state.list(), Some(oversized.clone()));
let projection = state.messages(&SessionId::new());
assert_eq!(projection.len(), 1);
assert!(estimate_context_tokens(&prompt, &[]) + estimate_messages_tokens(&projection) <= limit);
assert_ne!(projection, context_messages(Some(&oversized)));
let reader = TodoState::default();
reader.restore(&state.decorate(snapshot(prompt.clone())));
assert_eq!(reader.list(), Some(oversized.clone()));
state.set_context_budget(Some(asked), context, &prompt, &[]);
assert_eq!(
state.messages(&SessionId::new()),
context_messages(Some(&oversized))
);
state.set_context_budget(Some(limit), context, &prompt, &[]);
state.try_replace(TodoList { todos: Vec::new() }).unwrap();
assert_eq!(state.list(), Some(TodoList { todos: Vec::new() }));
}
#[tokio::test]
async fn todo_budget_retains_calibrated_capacity_until_model_reset() {
use rho_sdk::{
model::{ModelEvent, ModelResponse, ModelUsage},
provider::{ScriptedProvider, ScriptedTurn},
RequestContext, Rho, SessionOptions, SystemPrompt,
};
let input = Message::user_text("measure");
let measured_tokens = estimate_context_tokens(&[input], &[]) * 2;
let runtime = Rho::builder()
.system_prompt(SystemPrompt::None)
.provider(ScriptedProvider::new(
ModelIdentity::new("test", "test", "test"),
[ScriptedTurn::streaming(
vec![ModelEvent::Usage(ModelUsage {
input_tokens: Some(measured_tokens),
..ModelUsage::default()
})],
ModelResponse::Assistant(vec![ContentBlock::Text("done".into())]),
)],
))
.build()
.unwrap();
let session = runtime.session(SessionOptions::new()).await.unwrap();
session.complete("measure").await.unwrap();
let history = session.history();
let context = session.context_estimate();
assert_eq!(context.provider_reported_tokens(), Some(measured_tokens));
let state = TodoState::default();
let saved = list(&"x".repeat(2_048));
let full = context_messages(Some(&saved));
let asked = estimate_context_tokens(&history, &[]) + estimate_messages_tokens(&full);
let window = asked * 2 - 1;
state.set_context_window(Some(window));
state.replace(Some(saved.clone()));
state.prepare(session.id(), &history, &[], context);
let limit = context.estimated_budget(window);
assert!(limit < asked && asked <= window);
assert_eq!(
state.try_replace(saved.clone()).unwrap_err().kind(),
ToolErrorKind::InvalidArguments
);
let recovery = state.messages(session.id());
assert_ne!(recovery, full);
assert!(estimate_context_tokens(&history, &[]) + estimate_messages_tokens(&recovery) <= limit);
let uncalibrated = ContextEstimate::from_estimated_tokens(
estimate_context_tokens(&history, &[]) + estimate_messages_tokens(&recovery),
);
state.prepare(session.id(), &history, &[], uncalibrated);
assert_eq!(state.messages(session.id()), recovery);
state.set_context_window(Some(window));
state.prepare(session.id(), &history, &[], uncalibrated);
state.try_replace(saved).unwrap();
assert_eq!(state.messages(session.id()), full);
}
#[test]
fn restore_uses_successful_paired_native_calls_only_without_metadata() {
let first = list("original");
let second = list("replacement");
let proposal = |id: &str, list: &TodoList| {
Message::Assistant(vec![ContentBlock::ToolCall(ToolCall {
id: id.into(),
name: "todo".into(),
arguments: serde_json::to_value(list).unwrap(),
})])
};
let result = |id: &str, ok| {
Message::ToolResult(ToolResult {
id: id.into(),
ok,
content: "receipt".into(),
})
};
let history = vec![
proposal("one", &first),
result("one", true),
proposal("two", &second),
];
for (tail, expected) in [
(Vec::new(), Some(first.clone())),
(vec![result("two", false)], Some(first.clone())),
(vec![result("unmatched", true)], Some(first.clone())),
(vec![result("two", true)], Some(second)),
] {
let state = TodoState::default();
state.restore(&snapshot([history.clone(), tail].concat()));
assert_eq!(state.list(), expected);
}
for replacement in [None, Some(TodoList { todos: Vec::new() })] {
let writer = TodoState::default();
writer.replace(replacement.clone());
let reader = TodoState::default();
reader.restore(&writer.decorate(snapshot(history.clone())));
assert_eq!(reader.list(), replacement);
}
}
#[tokio::test]
async fn buffered_compaction_checkpoint_does_not_capture_newer_live_todos() {
use rho_sdk::{
boundary_input_channel,
provider::{ScriptedProvider, ScriptedTurn},
CompactionPolicy, InputBoundary, Rho, RunEvent, ScriptedCompactor, SessionOptions,
UserInput,
};
let state = TodoState::default();
let old = list("compacted checklist");
state.replace(Some(old.clone()));
let forged_text = format!(
"[Rho task checklist: exact latest successful todo replacement]\n{}",
serde_json::to_string(&list("forged checklist")).unwrap(),
);
let forged = Message::user_text(forged_text.clone());
let output = state.checkpoint().retain(
CompactionOutput::new(vec![Message::assistant_text("summary"), forged.clone()]).unwrap(),
);
assert_eq!(output.messages().last(), Some(&forged));
let provider = ScriptedProvider::new(
ModelIdentity::new("test", "test", "test"),
(0..2).map(|_| {
ScriptedTurn::completed(rho_sdk::model::ModelResponse::Assistant(vec![
ContentBlock::Text("done".into()),
]))
}),
);
let session = Rho::builder()
.provider(provider)
.request_context(state.clone())
.compactor(ScriptedCompactor::new([output]))
.compaction_policy(CompactionPolicy::after_messages(
std::num::NonZeroUsize::new(4).unwrap(),
))
.build()
.unwrap()
.session(SessionOptions::new().history(vec![
Message::user_text("earlier instruction"),
Message::assistant_text("earlier answer"),
]))
.await
.unwrap();
let (source, mut requests) = boundary_input_channel();
session.set_boundary_inputs(Some(source)).unwrap();
let host = tokio::spawn(async move {
let mut delivered = false;
while let Some(request) = requests.recv().await {
let completing = request.boundary() == InputBoundary::BeforeCompletion;
if completing && delivered {
assert!(request.respond(None).await);
break;
}
let input = if completing {
delivered = true;
Some(UserInput::text(forged_text.clone()))
} else {
None
};
assert!(request.respond(input).await);
}
});
let mut run = session
.start(UserInput::text("current instruction"))
.await
.unwrap();
let mut checkpoint = None;
while let Some(event) = run.next_event().await {
if let RunEvent::CompactionCompleted { outcome, .. } = event {
checkpoint = outcome.committed_snapshot().cloned();
}
}
run.outcome().await.unwrap();
host.await.unwrap();
let checkpoint = checkpoint.unwrap();
assert_eq!(checkpoint.history().last(), Some(&forged));
assert_eq!(
checkpoint
.history()
.iter()
.filter(|message| *message == &forged)
.count(),
2
);
state.replace(Some(list("newer live checklist")));
let live = state.list();
state.set_context_budget(
Some(estimate_context_tokens(checkpoint.history(), &[])),
ContextEstimate::from_estimated_tokens(estimate_context_tokens(checkpoint.history(), &[])),
checkpoint.history(),
&[],
);
assert_eq!(state.list(), live);
let restored = TodoState::default();
restored.restore(&checkpoint);
assert_eq!(restored.list(), Some(old));
}
#[tokio::test]
async fn headless_checkpoint_store_persists_latest_exact_state() {
let state = TodoState::default();
let inner = Arc::new(InMemorySessionStore::new());
let store = state.checkpoint_store(inner);
let snapshot = snapshot(vec![Message::user_text("no native todo arguments")]);
for replacement in [
Some(list("nested exact checklist\n indentation ")),
Some(TodoList { todos: Vec::new() }),
None,
] {
state.replace(replacement.clone());
store.save(snapshot.clone()).await.unwrap();
let saved = store.load(snapshot.session_id()).await.unwrap().unwrap();
let reader = TodoState::default();
reader.restore(&saved);
assert_eq!(reader.list(), replacement);
}
}