use atomcode_core::conversation::message::{Message, MessageContent, Role};
use atomcode_core::conversation::turn::{TurnStatus, TurnTracker};
use atomcode_core::conversation::Conversation;
use atomcode_core::tool::{ToolCall, ToolResult};
fn build_multi_turn_conversation(n: usize) -> Conversation {
let mut conv = Conversation::new();
for t in 0..n {
conv.add_user_message(&format!("task {}", t));
let msg_idx = conv.messages.len();
conv.messages.push(Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: Some(format!("working on task {}", t)),
tool_calls: vec![ToolCall {
id: format!("call_{}", t),
name: "bash".into(),
arguments: format!(r#"{{"command":"echo {}"}}"#, t),
}],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
});
conv.turn_tracker.on_message_added(msg_idx);
let msg_idx = conv.messages.len();
conv.messages.push(Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: format!("call_{}", t),
output: format!("output {}", t),
success: true,
}),
});
conv.turn_tracker.on_message_added(msg_idx);
let msg_idx = conv.messages.len();
conv.messages.push(Message::new(
Role::Assistant,
&format!("done with task {}", t),
));
conv.turn_tracker.on_message_added(msg_idx);
conv.turn_tracker.complete_current();
}
conv
}
#[test]
fn rebuild_produces_correct_turn_count_for_multi_turn_conversation() {
let conv = build_multi_turn_conversation(3);
assert_eq!(conv.messages.len(), 12);
let tracker = TurnTracker::rebuild(&conv.messages);
assert_eq!(tracker.turns.len(), 3, "should have 3 turns");
}
#[test]
fn rebuild_sets_completed_status_for_all_but_last_turn() {
let conv = build_multi_turn_conversation(3);
let tracker = TurnTracker::rebuild(&conv.messages);
assert_eq!(
tracker.turns[0].status,
TurnStatus::Completed,
"first turn should be Completed"
);
assert_eq!(
tracker.turns[1].status,
TurnStatus::Completed,
"middle turn should be Completed"
);
assert_eq!(
tracker.turns[2].status,
TurnStatus::Active,
"last turn should be Active (rebuild convention)"
);
}
#[test]
fn rebuild_correctly_tracks_message_indices_per_turn() {
let conv = build_multi_turn_conversation(3);
let tracker = TurnTracker::rebuild(&conv.messages);
assert_eq!(tracker.turns[0].start_idx, 0);
assert_eq!(tracker.turns[0].msg_count, 4);
assert_eq!(tracker.turns[0].end_idx(), 4);
assert_eq!(tracker.turns[1].start_idx, 4);
assert_eq!(tracker.turns[1].msg_count, 4);
assert_eq!(tracker.turns[1].end_idx(), 8);
assert_eq!(tracker.turns[2].start_idx, 8);
assert_eq!(tracker.turns[2].msg_count, 4);
assert_eq!(tracker.turns[2].end_idx(), 12);
}
#[test]
fn context_builds_with_turn_tracking_after_set_messages() {
use atomcode_core::config::provider::ProviderConfig;
use atomcode_core::ctx::CtxBuilder;
use atomcode_core::ctx::DefaultCtx;
let conv = build_multi_turn_conversation(3);
let messages = conv.messages.clone();
let turn_tracker = TurnTracker::rebuild(&messages);
let restored_conv = Conversation {
messages,
stream_buffer: None,
tool_call_buffer: None,
turn_tracker,
cold_summaries: Vec::new(),
};
assert!(
!restored_conv.turn_tracker.turns.is_empty(),
"restored conversation must have turn_tracker populated"
);
assert_eq!(restored_conv.turn_tracker.turns.len(), 3);
let provider_config = ProviderConfig {
provider_type: "test".into(),
api_key: None,
model: "test-model".into(),
base_url: None,
system_prompt: None,
user_agent: None,
context_window: 128_000,
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: true,
};
let ctx_builder = DefaultCtx::new(&provider_config);
let (built_msgs, stats) =
ctx_builder.build_messages(&restored_conv, "You are a helpful assistant.", "");
assert!(
built_msgs.len() > 1,
"should have system + at least some conversation messages, got {}",
built_msgs.len()
);
assert!(matches!(built_msgs[0].role, Role::System));
let user_msgs: Vec<_> = built_msgs
.iter()
.filter(|m| matches!(m.role, Role::User))
.collect();
assert_eq!(
user_msgs.len(),
3,
"all 3 user messages should be in the built context"
);
assert!(
stats.total_messages > 0,
"context stats should reflect the tracked messages"
);
}
#[test]
fn context_uses_fallback_when_turn_tracker_is_empty() {
use atomcode_core::config::provider::ProviderConfig;
use atomcode_core::ctx::CtxBuilder;
use atomcode_core::ctx::DefaultCtx;
let conv = build_multi_turn_conversation(3);
let buggy_conv = Conversation {
messages: conv.messages.clone(),
stream_buffer: None,
tool_call_buffer: None,
turn_tracker: TurnTracker::new(), cold_summaries: Vec::new(),
};
let provider_config = ProviderConfig {
provider_type: "test".into(),
api_key: None,
model: "test-model".into(),
base_url: None,
system_prompt: None,
user_agent: None,
context_window: 128_000,
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: true,
};
let ctx_builder = DefaultCtx::new(&provider_config);
let (built_msgs, _stats) =
ctx_builder.build_messages(&buggy_conv, "You are a helpful assistant.", "");
assert!(
built_msgs.len() > 1,
"fallback path should still produce conversation messages"
);
}
#[test]
fn session_round_trip_preserves_messages_and_turn_structure() {
use atomcode_core::session::{Session, SessionManager};
let tmp = tempfile::tempdir().unwrap();
let working_dir = tmp.path().to_path_buf();
let conv = build_multi_turn_conversation(2);
let mut session = Session::new(working_dir.clone());
session.messages = conv.messages.clone();
session.rename("test-session".into());
let session_manager = SessionManager::new(&working_dir);
session_manager.save(&session).unwrap();
let loaded = session_manager.load(&session.id).unwrap();
assert_eq!(
loaded.messages.len(),
conv.messages.len(),
"loaded session should have the same number of messages"
);
let tracker = TurnTracker::rebuild(&loaded.messages);
assert_eq!(
tracker.turns.len(),
2,
"rebuild from loaded session should produce 2 turns"
);
for (i, original) in conv.messages.iter().enumerate() {
assert_eq!(
loaded.messages[i].role, original.role,
"message {} role mismatch",
i
);
}
}
#[test]
fn rebuild_handles_tool_call_turns_correctly() {
let messages = vec![
Message::new(Role::User, "search for foo"),
Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: Some("searching...".into()),
tool_calls: vec![
ToolCall {
id: "c1".into(),
name: "grep".into(),
arguments: r#"{"pattern":"foo"}"#.into(),
},
ToolCall {
id: "c2".into(),
name: "read_file".into(),
arguments: r#"{"file_path":"/tmp/x.rs"}"#.into(),
},
],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
},
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: "c1".into(),
output: "found foo".into(),
success: true,
}),
},
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: "c2".into(),
output: "file contents".into(),
success: true,
}),
},
Message::new(Role::Assistant, "Here's what I found..."),
Message::new(Role::User, "now edit it"),
Message {
role: Role::Assistant,
content: MessageContent::AssistantWithToolCalls {
text: None,
tool_calls: vec![ToolCall {
id: "c3".into(),
name: "edit_file".into(),
arguments: r#"{"file_path":"/tmp/x.rs","old_string":"foo","new_string":"bar"}"#.into(),
}],
reasoning_content: None,
thinking_blocks: Vec::new(),
},
},
Message {
role: Role::Tool,
content: MessageContent::ToolResult(ToolResult {
call_id: "c3".into(),
output: "edit applied".into(),
success: true,
}),
},
];
let tracker = TurnTracker::rebuild(&messages);
assert_eq!(tracker.turns.len(), 2, "should have 2 turns");
assert_eq!(tracker.turns[0].start_idx, 0);
assert_eq!(tracker.turns[0].msg_count, 5);
assert_eq!(tracker.turns[0].status, TurnStatus::Completed);
assert_eq!(tracker.turns[1].start_idx, 5);
assert_eq!(tracker.turns[1].msg_count, 3);
assert_eq!(tracker.turns[1].status, TurnStatus::Active);
}
#[test]
fn set_messages_with_empty_list_produces_empty_tracker() {
let messages: Vec<Message> = Vec::new();
let tracker = TurnTracker::rebuild(&messages);
assert!(tracker.turns.is_empty());
}
#[test]
fn set_messages_with_single_user_message_produces_one_active_turn() {
let messages = vec![Message::new(Role::User, "hello")];
let tracker = TurnTracker::rebuild(&messages);
assert_eq!(tracker.turns.len(), 1);
assert_eq!(tracker.turns[0].start_idx, 0);
assert_eq!(tracker.turns[0].msg_count, 1);
assert_eq!(tracker.turns[0].status, TurnStatus::Active);
}
#[test]
fn restored_context_contains_same_user_messages_as_original() {
use atomcode_core::config::provider::ProviderConfig;
use atomcode_core::ctx::CtxBuilder;
use atomcode_core::ctx::DefaultCtx;
let provider_config = ProviderConfig {
provider_type: "test".into(),
api_key: None,
model: "test-model".into(),
base_url: None,
system_prompt: None,
user_agent: None,
context_window: 128_000,
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: true,
};
let ctx_builder = DefaultCtx::new(&provider_config);
let system_prompt = "You are a helpful assistant.";
let original_conv = build_multi_turn_conversation(3);
let (original_msgs, _) = ctx_builder.build_messages(&original_conv, system_prompt, "");
let messages = original_conv.messages.clone();
let turn_tracker = TurnTracker::rebuild(&messages);
let restored_conv = Conversation {
messages,
stream_buffer: None,
tool_call_buffer: None,
turn_tracker,
cold_summaries: Vec::new(),
};
let (restored_msgs, _) = ctx_builder.build_messages(&restored_conv, system_prompt, "");
let original_user_texts: Vec<&str> = original_msgs
.iter()
.filter(|m| matches!(m.role, Role::User))
.filter_map(|m| m.text())
.collect();
let restored_user_texts: Vec<&str> = restored_msgs
.iter()
.filter(|m| matches!(m.role, Role::User))
.filter_map(|m| m.text())
.collect();
assert_eq!(
original_user_texts, restored_user_texts,
"restored context must contain the same user messages as the original"
);
}
#[test]
fn empty_turn_tracker_loses_windowing_precision() {
use atomcode_core::config::provider::ProviderConfig;
use atomcode_core::ctx::CtxBuilder;
use atomcode_core::ctx::DefaultCtx;
let provider_config = ProviderConfig {
provider_type: "test".into(),
api_key: None,
model: "test-model".into(),
base_url: None,
system_prompt: None,
user_agent: None,
context_window: 128_000,
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: true,
};
let ctx_builder = DefaultCtx::new(&provider_config);
let conv = build_multi_turn_conversation(3);
let messages = conv.messages.clone();
let tracker = TurnTracker::rebuild(&messages);
let restored_conv = Conversation {
messages: messages.clone(),
stream_buffer: None,
tool_call_buffer: None,
turn_tracker: tracker,
cold_summaries: Vec::new(),
};
let (_, stats_with_tracker) = ctx_builder
.build_messages(&restored_conv, "You are a helpful assistant.", "");
let buggy_conv = Conversation {
messages,
stream_buffer: None,
tool_call_buffer: None,
turn_tracker: TurnTracker::new(),
cold_summaries: Vec::new(),
};
let (_, stats_without_tracker) = ctx_builder
.build_messages(&buggy_conv, "You are a helpful assistant.", "");
assert!(
stats_with_tracker.total_messages > 0,
"turn-tracked context should report total_messages > 0"
);
assert!(
stats_with_tracker.total_messages >= stats_without_tracker.total_messages,
"turn-tracked windowing should include at least as many messages as fallback"
);
}