use roder_api::artifacts::{ContextArtifactKind, format_artifact_reference};
use roder_api::context::{ContextBlockKind, ContextPlan, ContextQuery};
use roder_api::events::*;
use roder_api::retrieval::{RetrievalRoutePlan, RetrievalRoutePlanned};
use roder_api::transcript::{TranscriptItem, UserMessage};
use time::OffsetDateTime;
use crate::compaction::{
CompactionOptions, CompactionSkipReason, build_compaction_record, compaction_skip_reason,
estimate_prompt_tokens, format_llm_compaction_summary, head_items_for_summary_prompt,
model_entry_for_compaction, prune_avoids_full_compaction, prune_tool_outputs_in_transcript,
select_compaction_suffix, split_transcript_for_summarization, summarize_transcript,
trim_to_last_compaction_boundary,
};
use crate::runtime::{Runtime, StartTurnRequest};
use roder_api::artifacts::CreateArtifactRequest;
impl Runtime {
pub(crate) async fn transcript_for_turn(
&self,
req: &StartTurnRequest,
turn_id: &TurnId,
model: &str,
) -> anyhow::Result<Vec<TranscriptItem>> {
let context_plan = self.start_context_assembly(req, turn_id).await?;
let mut transcript = Vec::new();
for block in context_plan.blocks {
if matches!(
block.kind,
ContextBlockKind::Instruction
| ContextBlockKind::Memory
| ContextBlockKind::Knowledge
| ContextBlockKind::RepositoryFact
| ContextBlockKind::RetrievedDocument
| ContextBlockKind::PriorSummary
| ContextBlockKind::EntrypointHint
) {
transcript.push(TranscriptItem::UserMessage(UserMessage {
text: block.text,
images: Vec::new(),
}));
}
}
transcript.extend(self.prior_transcript(&req.thread_id, turn_id).await?);
transcript.push(TranscriptItem::UserMessage(UserMessage {
text: req.message.clone(),
images: req.images.clone(),
}));
let cfg = self.status().await;
let provider = req
.provider_override
.as_deref()
.unwrap_or(cfg.default_provider.as_str());
let transcript = self
.compact_transcript_if_needed(
&req.thread_id,
turn_id,
provider,
model,
transcript,
CompactionOptions::default(),
)
.await?;
self.complete_context_assembly(req, turn_id, &transcript)
.await;
Ok(transcript)
}
async fn start_context_assembly(
&self,
req: &StartTurnRequest,
turn_id: &TurnId,
) -> anyhow::Result<ContextPlan> {
self.emit(RoderEvent::ContextAssemblyStarted(ContextAssemblyStarted {
thread_id: req.thread_id.clone(),
turn_id: turn_id.clone(),
timestamp: OffsetDateTime::now_utc(),
}))
.await;
let query = ContextQuery {
thread_id: req.thread_id.clone(),
turn_id: turn_id.clone(),
prompt: req.message.clone(),
workspace: Some(req.workspace.clone()),
token_budget: None,
};
let mut blocks = self.skill_context_blocks(req, turn_id).await;
for provider in &self.registry.context_providers {
for block in provider.blocks(&query).await? {
self.emit(RoderEvent::ContextBlockAdded(ContextBlockAdded {
thread_id: req.thread_id.clone(),
turn_id: turn_id.clone(),
block_type: format!("{:?}", block.kind),
byte_count: block.text.len() as u64,
estimated_tokens: estimate_text_tokens(&block.text),
priority: block.priority,
timestamp: OffsetDateTime::now_utc(),
}))
.await;
blocks.push(block);
}
}
let plan = if self.registry.context_planners.is_empty() {
ContextPlan { blocks }
} else {
let mut plan = ContextPlan { blocks };
for planner in &self.registry.context_planners {
plan = planner.plan(&query, plan.blocks).await?;
}
plan
};
for block in plan
.blocks
.iter()
.filter(|block| matches!(block.kind, ContextBlockKind::EntrypointHint))
{
self.emit(RoderEvent::ContextEntrypointCandidatesInjected(
ContextEntrypointCandidatesInjected {
thread_id: req.thread_id.clone(),
turn_id: turn_id.clone(),
candidate_count: block
.metadata
.get("candidate_count")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
block_byte_count: block.text.len() as u64,
estimated_tokens: estimate_text_tokens(&block.text),
timestamp: OffsetDateTime::now_utc(),
},
))
.await;
}
for block in plan
.blocks
.iter()
.filter(|block| matches!(block.kind, ContextBlockKind::RetrievalHint))
{
if let Some(value) = block.metadata.get("retrievalPlan")
&& let Ok(plan) = serde_json::from_value::<RetrievalRoutePlan>(value.clone())
{
self.emit(RoderEvent::RetrievalRoutePlanned(RetrievalRoutePlanned {
plan,
}))
.await;
}
}
Ok(plan)
}
async fn complete_context_assembly(
&self,
req: &StartTurnRequest,
turn_id: &TurnId,
transcript: &[TranscriptItem],
) {
let block_count = transcript.len() as u64;
let total_byte_count = transcript
.iter()
.map(item_text_len)
.map(|len| len as u64)
.sum::<u64>();
let prompt_estimated_tokens = estimate_tokens(transcript);
self.emit(RoderEvent::ContextAssemblyCompleted(
ContextAssemblyCompleted {
thread_id: req.thread_id.clone(),
turn_id: turn_id.clone(),
block_count,
total_byte_count,
estimated_tokens: prompt_estimated_tokens,
prompt_estimated_tokens,
token_budget: None,
timestamp: OffsetDateTime::now_utc(),
},
))
.await;
}
async fn prior_transcript(
&self,
thread_id: &ThreadId,
current_turn_id: &TurnId,
) -> anyhow::Result<Vec<TranscriptItem>> {
let Some(store) = &self.thread_store else {
return Ok(Vec::new());
};
let Some(snapshot) = store.load_thread(thread_id).await? else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for turn in snapshot.turns {
if &turn.turn_id == current_turn_id {
continue;
}
out.extend(turn.items);
}
Ok(trim_to_last_compaction_boundary(out))
}
pub(crate) async fn compact_transcript_if_needed(
&self,
thread_id: &ThreadId,
turn_id: &TurnId,
provider: &str,
model: &str,
transcript: Vec<TranscriptItem>,
options: CompactionOptions,
) -> anyhow::Result<Vec<TranscriptItem>> {
let cfg = self.status().await;
let model_entry = model_entry_for_compaction(provider, model);
let threshold = cfg.auto_compact_token_limit;
if let Some(reason) = compaction_skip_reason(&transcript, model_entry, threshold, &options)
{
self.emit_compaction_skipped(
thread_id,
turn_id,
reason,
estimate_prompt_tokens(&transcript),
threshold,
None,
)
.await;
return Ok(transcript);
}
let mut working = transcript;
let prune_result = prune_tool_outputs_in_transcript(&working);
let pruned_tool_count = prune_result.pruned_tool_count;
if pruned_tool_count > 0 {
let tokens_saved = prune_result.tokens_saved;
working = prune_result.items;
if !options.force
&& tokens_saved >= crate::compaction::TOOL_OUTPUT_PRUNE_MIN_SAVINGS_TOKENS
&& prune_avoids_full_compaction(
&crate::compaction::ToolOutputPruneResult {
items: working.clone(),
pruned_tool_count,
tokens_saved,
},
model_entry,
threshold,
)
{
self.emit_compaction_skipped(
thread_id,
turn_id,
CompactionSkipReason::PruneSufficient,
estimate_prompt_tokens(&working),
threshold,
Some(pruned_tool_count),
)
.await;
return Ok(working);
}
}
if let Some(reason) = compaction_skip_reason(&working, model_entry, threshold, &options) {
self.emit_compaction_skipped(
thread_id,
turn_id,
reason,
estimate_prompt_tokens(&working),
threshold,
Some(pruned_tool_count),
)
.await;
return Ok(working);
}
let estimated_tokens = estimate_prompt_tokens(&working);
let original_item_count = working.len() as u64;
let original_estimated_tokens = estimated_tokens;
let compaction_started_at = std::time::Instant::now();
self.emit(RoderEvent::ContextCompactionStarted(
ContextCompactionStarted {
thread_id: thread_id.clone(),
turn_id: turn_id.clone(),
original_item_count,
original_estimated_tokens,
timestamp: OffsetDateTime::now_utc(),
},
))
.await;
let split = split_transcript_for_summarization(&working);
let suffix = if split.tail.is_empty() {
select_compaction_suffix(&working)
} else {
split.tail
};
let summary_head = head_items_for_summary_prompt(&split.head);
let (summary, strategy) = self
.build_compaction_summary(
thread_id,
turn_id,
provider,
model,
&summary_head,
&working,
options.preserve_hint.as_deref(),
cfg.file_backed_dynamic_context,
)
.await?;
let compaction = build_compaction_record(summary);
self.persist_turn_item(thread_id, turn_id, &compaction)
.await?;
let mut compacted = vec![compaction];
compacted.extend(suffix);
self.record_compaction_hysteresis(thread_id, original_estimated_tokens);
let compacted_estimated_tokens = estimate_prompt_tokens(&compacted);
let duration_ms = u64::try_from(compaction_started_at.elapsed().as_millis()).ok();
self.emit(RoderEvent::ContextCompactionRecorded(
ContextCompactionRecorded {
thread_id: thread_id.clone(),
turn_id: turn_id.clone(),
original_item_count,
original_estimated_tokens,
compacted_item_count: compacted.len() as u64,
compacted_estimated_tokens,
file_backed: cfg.file_backed_dynamic_context,
strategy: Some(strategy),
pruned_tool_count: Some(pruned_tool_count),
duration_ms,
timestamp: OffsetDateTime::now_utc(),
},
))
.await;
Ok(compacted)
}
async fn emit_compaction_skipped(
&self,
thread_id: &ThreadId,
turn_id: &TurnId,
reason: CompactionSkipReason,
estimated_tokens: u32,
threshold: Option<u32>,
pruned_tool_count: Option<u32>,
) {
if matches!(
reason,
CompactionSkipReason::BelowThreshold | CompactionSkipReason::AlreadyCompactedThisTurn
) {
return;
}
self.emit(RoderEvent::ContextCompactionSkipped(
ContextCompactionSkipped {
thread_id: thread_id.clone(),
turn_id: turn_id.clone(),
reason: reason.as_str().to_string(),
estimated_tokens,
threshold,
pruned_tool_count,
timestamp: OffsetDateTime::now_utc(),
},
))
.await;
}
async fn build_compaction_summary(
&self,
thread_id: &ThreadId,
turn_id: &TurnId,
provider: &str,
model: &str,
head: &[TranscriptItem],
full_transcript: &[TranscriptItem],
preserve_hint: Option<&str>,
file_backed: bool,
) -> anyhow::Result<(String, String)> {
let mut used_llm = false;
let mut summary = if head.is_empty() {
summarize_transcript(full_transcript)
} else if let Some(llm_summary) = self
.summarize_compaction_head(provider, model, head, preserve_hint)
.await?
{
used_llm = true;
format_llm_compaction_summary(&llm_summary)
} else {
summarize_transcript(full_transcript)
};
let mut strategy = if used_llm {
"llm".to_string()
} else {
"deterministic".to_string()
};
if file_backed {
let history_json = serde_json::to_vec_pretty(full_transcript)?;
let artifact = self.context_artifacts().create(CreateArtifactRequest {
kind: ContextArtifactKind::ChatHistory,
thread_id,
turn_id,
source_tool_id: None,
label: Some("pre-compaction transcript"),
bytes: &history_json,
})?;
self.emit(RoderEvent::ContextArtifactCreated(ContextArtifactCreated {
thread_id: thread_id.clone(),
turn_id: turn_id.clone(),
artifact: artifact.clone(),
timestamp: OffsetDateTime::now_utc(),
}))
.await;
let reference = format_artifact_reference(&artifact, "pre-compaction transcript");
summary = format!("{summary}\n\n{reference}");
if strategy == "llm" {
strategy = "llm_file_backed".to_string();
} else {
strategy = "deterministic_file_backed".to_string();
}
}
Ok((summary, strategy))
}
}
fn estimate_tokens(items: &[TranscriptItem]) -> u32 {
estimate_prompt_tokens(items)
}
fn estimate_text_tokens(text: &str) -> u32 {
u32::try_from(text.len().div_ceil(4)).unwrap_or(u32::MAX)
}
fn item_text_len(item: &TranscriptItem) -> usize {
match item {
TranscriptItem::UserMessage(message) => message.text.len(),
TranscriptItem::AssistantMessage(message) => message.text.len(),
TranscriptItem::ReasoningSummary(summary) => summary.text.len(),
TranscriptItem::ToolCall(call) => call.arguments.len() + call.name.len(),
TranscriptItem::ToolResult(result) => result.result.len(),
TranscriptItem::FileChange(change) => change.path.len() + change.change_type.len(),
TranscriptItem::ContextCompaction(compaction) => compaction.summary.len(),
TranscriptItem::Error(error) => error.message.len(),
TranscriptItem::ProviderMetadata(_) => 0,
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use std::sync::Arc;
use roder_api::catalog::PROVIDER_MOCK;
use roder_api::extension::ExtensionRegistryBuilder;
use roder_api::skills::{SkillExposure, SkillSelector};
use roder_api::transcript::{AssistantMessage, ErrorRecord, ToolResultRecord, UserMessage};
use roder_ext_jsonl_thread_store::store::JsonlThreadStoreFactory;
use roder_skills::{SkillConfigRule, SkillRegistry, SkillRegistryOptions, SkillRoot};
use crate::compaction::{
CompactionOptions, build_compaction_record, should_compact_transcript,
transcript_contains_context_limit_failure, truncate,
};
use crate::fake_provider::FakeInferenceEngine;
use crate::runtime::{
DEFAULT_EXTERNAL_TOOL_TIMEOUT_SECONDS, Runtime, RuntimeConfig, StartTurnRequest,
};
use super::*;
fn test_workspace() -> String {
std::env::current_dir().unwrap().display().to_string()
}
#[tokio::test]
async fn skills_context_renders_global_index_and_direct_invocation() {
let workspace = fixture_dir("skills-context");
write_skill(
&workspace.join(".agents/skills/review"),
"review",
"Review code changes",
SkillExposure::Global,
);
let skills = SkillRegistry::load(SkillRegistryOptions {
workspace: workspace.clone(),
include_builtins: true,
roots: vec![SkillRoot::workspace(
workspace.join(".agents/skills"),
"workspace://.agents/skills",
)],
workflow_imports: Vec::new(),
config_rules: Vec::new(),
});
let runtime = runtime_with_skills(skills).await;
let mut events = runtime.subscribe_events();
let transcript = runtime
.transcript_for_turn(
&turn_request("thread-skills", "please use ${vcs-snapshot}"),
&"turn-skills".to_string(),
"mock",
)
.await
.unwrap();
let texts = transcript_texts(&transcript);
let index = texts
.iter()
.find(|text| text.starts_with("<skills>"))
.expect("global skill index");
assert!(index.contains("review"));
assert!(index.contains("roder-config"));
assert!(!index.contains("vcs-snapshot"));
assert!(texts.iter().any(|text| {
text.starts_with("<skill name=\"vcs-snapshot\"") && text.contains("VCS status")
}));
let emitted = drain_events(&mut events);
assert!(emitted.iter().any(|event| {
matches!(
event,
RoderEvent::SkillIndexRendered(rendered)
if rendered.rendered_count == 2 && rendered.hidden_count >= 1
)
}));
assert!(emitted.iter().any(|event| {
matches!(
event,
RoderEvent::SkillInvoked(invoked)
if invoked.descriptor.name == "vcs-snapshot"
)
}));
assert!(emitted.iter().any(|event| {
matches!(
event,
RoderEvent::ContextAssemblyCompleted(completed)
if completed.prompt_estimated_tokens > 0
&& completed.prompt_estimated_tokens == completed.estimated_tokens
)
}));
}
#[tokio::test]
async fn skills_disabled_direct_skill_does_not_inject() {
let skills = SkillRegistry::load(SkillRegistryOptions {
workspace: PathBuf::new(),
include_builtins: true,
roots: Vec::new(),
workflow_imports: Vec::new(),
config_rules: vec![SkillConfigRule {
name: Some("vcs-snapshot".to_string()),
path: None,
enabled: Some(false),
exposure: None,
}],
});
let runtime = runtime_with_skills(skills).await;
let mut events = runtime.subscribe_events();
let transcript = runtime
.transcript_for_turn(
&turn_request("thread-disabled", "please use ${vcs-snapshot}"),
&"turn-disabled".to_string(),
"mock",
)
.await
.unwrap();
let texts = transcript_texts(&transcript);
assert!(!texts.iter().any(|text| {
text.starts_with("<skill name=\"vcs-snapshot\"") && text.contains("VCS status")
}));
let emitted = drain_events(&mut events);
assert!(emitted.iter().any(|event| {
matches!(
event,
RoderEvent::SkillSkipped(skipped)
if skipped.selector == (SkillSelector::Name { name: "vcs-snapshot".to_string() })
&& skipped.reason.contains("disabled")
)
}));
}
#[tokio::test]
async fn context_compaction_writes_history_artifact_and_emits_metrics() {
let mut builder = ExtensionRegistryBuilder::new();
builder.inference_engine(Arc::new(FakeInferenceEngine));
let thread_root =
std::env::temp_dir().join(format!("roder-thread-artifacts-{}", uuid::Uuid::new_v4()));
builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
base_path: thread_root.clone(),
}));
let runtime = Runtime::new(
builder.build().unwrap(),
RuntimeConfig {
default_provider: PROVIDER_MOCK.to_string(),
default_model: "mock".to_string(),
reasoning: None,
auto_compact_token_limit: Some(1),
file_backed_dynamic_context: true,
hosted_web_search: roder_api::inference::HostedWebSearchConfig::disabled(),
tool_search: Default::default(),
provider_tool_search: std::collections::HashMap::new(),
model_tool_search: std::collections::HashMap::new(),
model_edit_tools: std::collections::HashMap::new(),
model_parallel_tool_calls: std::collections::HashMap::new(),
model_profiles: std::collections::HashMap::new(),
tool_allowlist: Vec::new(),
external_tool_timeout_seconds: DEFAULT_EXTERNAL_TOOL_TIMEOUT_SECONDS,
command_shell: roder_api::command_shell::default_command_shell(),
workspace: None,
policy_mode: roder_api::policy_mode::PolicyMode::Default,
agent_swarm_mode: false,
runtime_profile: roder_api::inference::RuntimeProfile::Interactive,
inference_router: crate::inference_routing::RuntimeInferenceRouterConfig::default(),
speed_policy: Default::default(),
dynamic_workflows: Default::default(),
reliability: Default::default(),
turn_deadline_seconds: None,
remote_runner_destination: None,
team_data_dir: None,
roadmap_data_dir: None,
media_generation: Default::default(),
},
)
.unwrap();
let thread_id = runtime
.create_thread(Some("Context compaction".to_string()))
.await
.unwrap()
.thread_id;
let mut events = runtime.subscribe_events();
let compacted = runtime
.compact_transcript_if_needed(
&thread_id,
&"turn".to_string(),
PROVIDER_MOCK,
"mock",
vec![
TranscriptItem::UserMessage(UserMessage {
text: "very large old context".repeat(20),
images: Vec::new(),
}),
TranscriptItem::AssistantMessage(AssistantMessage {
text: "old answer".to_string(),
phase: None,
}),
TranscriptItem::UserMessage(UserMessage {
text: "current prompt".to_string(),
images: Vec::new(),
}),
],
CompactionOptions::default(),
)
.await
.unwrap();
assert!(matches!(
&compacted[0],
TranscriptItem::ContextCompaction(summary)
if summary.summary.contains("Previous transcript was compacted")
&& summary.summary.contains("read_artifact")
));
assert!(compacted.iter().any(|item| matches!(
item,
TranscriptItem::UserMessage(message) if message.text == "current prompt"
)));
let artifacts = runtime
.context_artifacts()
.list_artifacts(&thread_id)
.unwrap();
let history = artifacts
.iter()
.find(|artifact| {
artifact.kind == roder_api::artifacts::ContextArtifactKind::ChatHistory
})
.unwrap();
let grep = runtime
.context_artifacts()
.grep_artifact(&thread_id, &history.id, "old answer", 0, 10)
.unwrap();
assert_eq!(grep.total_matches, 1);
assert!(
history.store_path.starts_with(
thread_root
.join(&thread_id)
.join("artifacts")
.join("turn")
.to_string_lossy()
.as_ref()
)
);
let emitted = drain_events(&mut events);
assert!(emitted.iter().any(|event| {
matches!(
event,
RoderEvent::ContextCompactionRecorded(recorded)
if recorded.original_item_count == 3
&& recorded.compacted_item_count >= 2
&& recorded.original_estimated_tokens > 0
&& recorded.compacted_estimated_tokens > 0
&& recorded.file_backed
)
}));
let _ = std::fs::remove_dir_all(thread_root);
}
#[tokio::test]
async fn openai_server_side_compaction_models_skip_local_summary_compaction() {
let mut builder = ExtensionRegistryBuilder::new();
builder.inference_engine(Arc::new(FakeInferenceEngine));
let runtime = Runtime::new(
builder.build().unwrap(),
RuntimeConfig {
default_provider: PROVIDER_MOCK.to_string(),
default_model: "mock".to_string(),
reasoning: None,
auto_compact_token_limit: Some(1),
file_backed_dynamic_context: true,
hosted_web_search: roder_api::inference::HostedWebSearchConfig::disabled(),
tool_search: Default::default(),
provider_tool_search: std::collections::HashMap::new(),
model_tool_search: std::collections::HashMap::new(),
model_edit_tools: std::collections::HashMap::new(),
model_parallel_tool_calls: std::collections::HashMap::new(),
model_profiles: std::collections::HashMap::new(),
tool_allowlist: Vec::new(),
external_tool_timeout_seconds: DEFAULT_EXTERNAL_TOOL_TIMEOUT_SECONDS,
command_shell: roder_api::command_shell::default_command_shell(),
workspace: None,
policy_mode: roder_api::policy_mode::PolicyMode::Default,
agent_swarm_mode: false,
runtime_profile: roder_api::inference::RuntimeProfile::Interactive,
inference_router: crate::inference_routing::RuntimeInferenceRouterConfig::default(),
speed_policy: Default::default(),
dynamic_workflows: Default::default(),
reliability: Default::default(),
turn_deadline_seconds: None,
remote_runner_destination: None,
team_data_dir: None,
roadmap_data_dir: None,
media_generation: Default::default(),
},
)
.unwrap();
let transcript = vec![
TranscriptItem::UserMessage(UserMessage {
text: "very large old context".repeat(20),
images: Vec::new(),
}),
TranscriptItem::AssistantMessage(AssistantMessage {
text: "old answer".to_string(),
phase: None,
}),
TranscriptItem::UserMessage(UserMessage {
text: "current prompt".to_string(),
images: Vec::new(),
}),
];
let compacted = runtime
.compact_transcript_if_needed(
&"thread".to_string(),
&"turn".to_string(),
PROVIDER_MOCK,
"gpt-5.5",
transcript.clone(),
CompactionOptions::default(),
)
.await
.unwrap();
assert_eq!(compacted, transcript);
}
#[test]
fn truncate_does_not_split_multibyte_chars() {
let text = format!("{}\u{2014}tail", "a".repeat(238));
let truncated = truncate(&text);
assert!(truncated.ends_with("..."));
assert!(truncated.len() <= 240 + 3);
}
#[test]
fn prompt_too_long_errors_are_recognized_as_context_limit_failures() {
for message in [
"Prompt is too long: 1048576 tokens > 1000000 maximum",
"API Error: 400 prompt too long",
"Your input exceeds the context window of this model.",
] {
let items = vec![TranscriptItem::Error(ErrorRecord {
message: message.to_string(),
})];
assert!(
transcript_contains_context_limit_failure(&items),
"expected context-limit failure for message: {message}"
);
}
}
#[tokio::test]
async fn claude_code_models_compact_locally_on_the_fly() {
let mut builder = ExtensionRegistryBuilder::new();
builder.inference_engine(Arc::new(FakeInferenceEngine));
let thread_root = std::env::temp_dir().join(format!(
"roder-claude-code-compaction-{}",
uuid::Uuid::new_v4()
));
builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
base_path: thread_root.clone(),
}));
let runtime = Runtime::new(
builder.build().unwrap(),
RuntimeConfig {
default_provider: PROVIDER_MOCK.to_string(),
default_model: "mock".to_string(),
reasoning: None,
auto_compact_token_limit: Some(1),
file_backed_dynamic_context: true,
..RuntimeConfig::default()
},
)
.unwrap();
let thread_id = runtime
.create_thread(Some("Claude Code compaction".to_string()))
.await
.unwrap()
.thread_id;
let transcript = vec![
TranscriptItem::UserMessage(UserMessage {
text: "very large old context".repeat(20),
images: Vec::new(),
}),
TranscriptItem::AssistantMessage(AssistantMessage {
text: "old answer".to_string(),
phase: None,
}),
TranscriptItem::UserMessage(UserMessage {
text: "current prompt".to_string(),
images: Vec::new(),
}),
];
let compacted = runtime
.compact_transcript_if_needed(
&thread_id,
&"turn".to_string(),
PROVIDER_MOCK,
"sonnet",
transcript,
CompactionOptions::default(),
)
.await
.unwrap();
assert!(matches!(
&compacted[0],
TranscriptItem::ContextCompaction(summary)
if summary.summary.contains("Previous transcript was compacted")
));
assert!(compacted.iter().any(|item| matches!(
item,
TranscriptItem::UserMessage(message) if message.text == "current prompt"
)));
let _ = std::fs::remove_dir_all(thread_root);
}
#[tokio::test]
async fn context_window_failure_forces_local_compaction_on_server_side_models() {
let mut builder = ExtensionRegistryBuilder::new();
builder.inference_engine(Arc::new(FakeInferenceEngine));
let thread_root = std::env::temp_dir().join(format!(
"roder-context-failure-compaction-{}",
uuid::Uuid::new_v4()
));
builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
base_path: thread_root.clone(),
}));
let runtime = Runtime::new(
builder.build().unwrap(),
RuntimeConfig {
default_provider: PROVIDER_MOCK.to_string(),
default_model: "mock".to_string(),
reasoning: None,
file_backed_dynamic_context: true,
..RuntimeConfig::default()
},
)
.unwrap();
let thread_id = runtime
.create_thread(Some("Context failure compaction".to_string()))
.await
.unwrap()
.thread_id;
let transcript = vec![
TranscriptItem::UserMessage(UserMessage {
text: "work already done ".repeat(20),
images: Vec::new(),
}),
TranscriptItem::Error(ErrorRecord {
message: "Your input exceeds the context window of this model. Please adjust your input and try again."
.to_string(),
}),
TranscriptItem::UserMessage(UserMessage {
text: "continue".to_string(),
images: Vec::new(),
}),
];
let compacted = runtime
.compact_transcript_if_needed(
&thread_id,
&"turn".to_string(),
PROVIDER_MOCK,
"gpt-5.5",
transcript,
CompactionOptions::default(),
)
.await
.unwrap();
assert!(matches!(
&compacted[0],
TranscriptItem::ContextCompaction(summary)
if summary.summary.contains("Previous transcript was compacted")
));
assert!(compacted.iter().any(|item| matches!(
item,
TranscriptItem::UserMessage(message) if message.text == "continue"
)));
let _ = std::fs::remove_dir_all(thread_root);
}
#[tokio::test]
async fn prune_can_avoid_full_compaction_for_tool_heavy_transcript() {
let mut builder = ExtensionRegistryBuilder::new();
builder.inference_engine(Arc::new(FakeInferenceEngine));
let runtime = Runtime::new(
builder.build().unwrap(),
RuntimeConfig {
auto_compact_token_limit: Some(50_000),
..RuntimeConfig::default()
},
)
.unwrap();
let mut events = runtime.subscribe_events();
let transcript = vec![
TranscriptItem::UserMessage(UserMessage::text("start")),
TranscriptItem::ToolResult(ToolResultRecord {
id: "old".to_string(),
name: Some("grep".to_string()),
result: "x".repeat(120_000),
display_payload: None,
is_error: false,
}),
TranscriptItem::UserMessage(UserMessage::text("current")),
TranscriptItem::ToolResult(ToolResultRecord {
id: "recent-buffer".to_string(),
name: Some("read".to_string()),
result: "y".repeat(180_000),
display_payload: None,
is_error: false,
}),
TranscriptItem::ToolResult(ToolResultRecord {
id: "new".to_string(),
name: Some("read".to_string()),
result: "fresh".to_string(),
display_payload: None,
is_error: false,
}),
];
let result = runtime
.compact_transcript_if_needed(
&"thread".to_string(),
&"turn".to_string(),
PROVIDER_MOCK,
"mock",
transcript,
CompactionOptions::default(),
)
.await
.unwrap();
assert!(
!result
.iter()
.any(|item| { matches!(item, TranscriptItem::ContextCompaction(_)) })
);
let emitted = drain_events(&mut events);
assert!(emitted.iter().any(|event| matches!(
event,
RoderEvent::ContextCompactionSkipped(skipped)
if skipped.reason == "prune_sufficient"
)));
}
#[tokio::test]
async fn force_compact_uses_llm_snapshot_when_available() {
let mut builder = ExtensionRegistryBuilder::new();
builder.inference_engine(Arc::new(FakeInferenceEngine));
let thread_root =
std::env::temp_dir().join(format!("roder-force-compact-{}", uuid::Uuid::new_v4()));
builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
base_path: thread_root.clone(),
}));
let runtime = Runtime::new(
builder.build().unwrap(),
RuntimeConfig {
auto_compact_token_limit: Some(1),
file_backed_dynamic_context: false,
..RuntimeConfig::default()
},
)
.unwrap();
let thread_id = runtime
.create_thread(Some("Force compact".to_string()))
.await
.unwrap()
.thread_id;
runtime
.persist_turn_item(
&thread_id,
&"turn-1".to_string(),
&TranscriptItem::UserMessage(UserMessage::text("old".repeat(200))),
)
.await
.unwrap();
runtime
.persist_turn_item(
&thread_id,
&"turn-1".to_string(),
&TranscriptItem::UserMessage(UserMessage::text("current")),
)
.await
.unwrap();
let outcome = runtime
.force_compact_thread(
&thread_id,
&"turn-1".to_string(),
Some("keep current prompt".to_string()),
)
.await
.unwrap();
assert!(outcome.compacted);
let _ = std::fs::remove_dir_all(thread_root);
}
#[tokio::test]
async fn repeat_compaction_within_turn_is_blocked_after_first_summary() {
let items = vec![
build_compaction_record("summary".to_string()),
TranscriptItem::UserMessage(UserMessage::text("x".repeat(20_000))),
];
let options = CompactionOptions {
allow_repeat: false,
..CompactionOptions::default()
};
assert!(!should_compact_transcript(&items, None, Some(1), &options));
}
#[tokio::test]
async fn compaction_without_file_backed_context_keeps_legacy_summary_only() {
let mut builder = ExtensionRegistryBuilder::new();
builder.inference_engine(Arc::new(FakeInferenceEngine));
let thread_root = std::env::temp_dir().join(format!(
"roder-thread-artifacts-disabled-{}",
uuid::Uuid::new_v4()
));
builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
base_path: thread_root.clone(),
}));
let runtime = Runtime::new(
builder.build().unwrap(),
RuntimeConfig {
default_provider: PROVIDER_MOCK.to_string(),
default_model: "mock".to_string(),
reasoning: None,
auto_compact_token_limit: Some(1),
file_backed_dynamic_context: false,
hosted_web_search: roder_api::inference::HostedWebSearchConfig::disabled(),
tool_search: Default::default(),
provider_tool_search: std::collections::HashMap::new(),
model_tool_search: std::collections::HashMap::new(),
model_edit_tools: std::collections::HashMap::new(),
model_parallel_tool_calls: std::collections::HashMap::new(),
model_profiles: std::collections::HashMap::new(),
tool_allowlist: Vec::new(),
external_tool_timeout_seconds: DEFAULT_EXTERNAL_TOOL_TIMEOUT_SECONDS,
command_shell: roder_api::command_shell::default_command_shell(),
workspace: None,
roadmap_data_dir: None,
policy_mode: roder_api::policy_mode::PolicyMode::Default,
agent_swarm_mode: false,
runtime_profile: roder_api::inference::RuntimeProfile::Interactive,
inference_router: crate::inference_routing::RuntimeInferenceRouterConfig::default(),
speed_policy: Default::default(),
dynamic_workflows: Default::default(),
reliability: Default::default(),
turn_deadline_seconds: None,
remote_runner_destination: None,
team_data_dir: None,
media_generation: Default::default(),
},
)
.unwrap();
let thread_id = runtime
.create_thread(Some("Legacy compaction".to_string()))
.await
.unwrap()
.thread_id;
let compacted = runtime
.compact_transcript_if_needed(
&thread_id,
&"turn".to_string(),
PROVIDER_MOCK,
"mock",
vec![
TranscriptItem::UserMessage(UserMessage {
text: "very large old context".repeat(20),
images: Vec::new(),
}),
TranscriptItem::AssistantMessage(AssistantMessage {
text: "old answer".to_string(),
phase: None,
}),
TranscriptItem::UserMessage(UserMessage {
text: "current prompt".to_string(),
images: Vec::new(),
}),
],
CompactionOptions::default(),
)
.await
.unwrap();
assert!(matches!(
&compacted[0],
TranscriptItem::ContextCompaction(summary)
if summary.summary.contains("Previous transcript was compacted")
&& !summary.summary.contains("read_artifact")
));
assert!(
runtime
.context_artifacts()
.list_artifacts(&thread_id)
.unwrap()
.is_empty()
);
let _ = std::fs::remove_dir_all(thread_root);
}
fn drain_events(
rx: &mut tokio::sync::broadcast::Receiver<roder_api::events::EventEnvelope>,
) -> Vec<RoderEvent> {
let mut events = Vec::new();
loop {
match rx.try_recv() {
Ok(envelope) => events.push(envelope.event),
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break,
}
}
events
}
async fn runtime_with_skills(skills: SkillRegistry) -> Runtime {
let mut builder = ExtensionRegistryBuilder::new();
builder.inference_engine(Arc::new(FakeInferenceEngine));
let runtime = Runtime::new(builder.build().unwrap(), RuntimeConfig::default()).unwrap();
runtime.set_skills(skills).await;
runtime
}
fn turn_request(thread_id: &str, message: &str) -> StartTurnRequest {
StartTurnRequest {
thread_id: thread_id.to_string(),
message: message.to_string(),
images: Vec::new(),
provider_override: None,
model_override: None,
reasoning_override: None,
workspace: test_workspace(),
instructions: Default::default(),
developer_context: None,
task_ledger_required: false,
}
}
fn transcript_texts(transcript: &[TranscriptItem]) -> Vec<&str> {
transcript
.iter()
.filter_map(|item| match item {
TranscriptItem::UserMessage(message) => Some(message.text.as_str()),
_ => None,
})
.collect()
}
fn write_skill(dir: &Path, name: &str, description: &str, exposure: SkillExposure) {
std::fs::create_dir_all(dir).unwrap();
let exposure = match exposure {
SkillExposure::Global => "global",
SkillExposure::DirectOnly => "direct_only",
};
std::fs::write(
dir.join("SKILL.md"),
format!(
"---\nname: {name}\ndescription: {description}\nexposure: {exposure}\n---\nBody for {name}\n"
),
)
.unwrap();
}
fn fixture_dir(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("roder-core-{name}-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
root
}
}