use crate::context::auto_compaction::{CompactionConfig, CompactionReason};
use crate::extensions::{
ExtensionContext, ExtensionContextBuilder, ExtensionRunner, SessionShutdownEvent,
SessionShutdownReason,
};
use crate::store::session::{AgentMessage, SessionManager};
use crate::store::settings::{Settings, ThinkingLevel};
use anyhow::{Context, Result};
use oxi_agent::advisor::{
AdviseTool, AdvisorDeliveryChannel, AdvisorEmissionGuard, AdvisorNote, AdvisorRuntime,
AdvisorRuntimeHost, AgentAdvisor, DeliveryOpts, EnqueueAdviceFn, format_advisory_batch,
resolve_delivery_channel,
};
use oxi_agent::{
Agent, AgentConfig, AgentEvent, AgentState, FindTool, GrepTool, ReadTool, ToolRegistry,
};
use oxi_ai::ModelRole;
use oxi_sdk::{
CompactionStrategy, Message, Provider, RoleRegistry, RoleRoutingProvider, get_provider,
resolve_role_to_model,
};
use parking_lot::{Mutex as PlMutex, RwLock};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::Mutex;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum SessionEvent {
QueueUpdate {
steering: Vec<String>,
follow_up: Vec<String>,
},
CompactionStart {
reason: CompactionReason,
},
CompactionEnd {
reason: CompactionReason,
error_message: Option<String>,
},
SessionInfoChanged,
Agent(Box<AgentEvent>),
ThinkingLevelChanged {
level: ThinkingLevel,
},
Advisor {
channel: AdvisorDeliveryChannel,
body: String,
},
}
#[derive(Debug, Clone)]
pub struct CompactionResult {
pub tokens_before: usize,
}
#[derive(Debug, Clone)]
pub struct ScopedModel {
pub provider: String,
pub model_id: String,
}
#[derive(Debug, Clone)]
pub struct SessionStats {
pub session_id: String,
pub user_messages: usize,
pub assistant_messages: usize,
pub tool_calls: usize,
pub tool_results: usize,
pub total_messages: usize,
}
pub struct AgentSession {
agent: Arc<Agent>,
settings: Arc<RwLock<Settings>>,
session_manager: Arc<RwLock<SessionManager>>,
#[allow(clippy::type_complexity)]
listeners: Arc<RwLock<Vec<Box<dyn Fn(&SessionEvent) + Send + Sync>>>>,
scoped_models: Arc<RwLock<Vec<ScopedModel>>>,
steering_messages: Arc<RwLock<VecDeque<String>>>,
follow_up_messages: Arc<RwLock<VecDeque<String>>>,
compaction_config: Arc<RwLock<CompactionConfig>>,
compaction_abort: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
overflow_recovery_attempted: Arc<RwLock<bool>>,
session_id: Arc<RwLock<String>>,
cwd: String,
streaming: Arc<AtomicBool>,
should_stop: Arc<AtomicBool>,
extension_runner: Arc<RwLock<Option<ExtensionRunner>>>,
advisor: Arc<RwLock<Option<Arc<AdvisorRuntime>>>>,
advisor_guard: Arc<AdvisorEmissionGuard>,
advisor_delivery: Arc<PlMutex<AdvisorDeliveryState>>,
advisor_primary_turns: Arc<std::sync::atomic::AtomicU64>,
}
#[derive(Debug, Default, Clone, Copy)]
struct AdvisorDeliveryState {
auto_resume_suppressed: bool,
interrupt_immune_turn_start: Option<u64>,
}
struct AdvisorHost {
agent: Arc<Agent>,
guard: Arc<AdvisorEmissionGuard>,
}
impl AdvisorRuntimeHost for AdvisorHost {
fn snapshot_messages(&self) -> Vec<Message> {
self.agent.state().messages
}
fn begin_advisor_update(&self) {
self.guard.begin_update();
}
fn enqueue_advice(&self, _note: AdvisorNote) {}
}
fn resume_messages_from_branch(
entries: &[crate::store::session::SessionEntry],
) -> Vec<oxi_ai::Message> {
use crate::store::session::AssistantContentBlock;
use oxi_ai::{
AssistantMessage, ContentBlock, ImageContent, Message, TextContent, ThinkingContent,
ToolCall,
};
use std::collections::HashMap;
let last_compaction = entries
.iter()
.rposition(|e| matches!(e.message, AgentMessage::CompactionSummary { .. }));
let relevant = &entries[last_compaction.unwrap_or(0)..];
let mut tool_names: HashMap<String, String> = HashMap::new();
let mut out: Vec<Message> = Vec::new();
for entry in relevant {
match &entry.message {
AgentMessage::CompactionSummary { summary, .. } => {
if !summary.is_empty() {
out.push(Message::user(format!(
"[Summary of earlier conversation]\n{summary}"
)));
}
}
AgentMessage::User { content } => {
out.push(Message::user(content.as_str().to_string()));
}
AgentMessage::Assistant {
content,
provider,
model_id,
..
} => {
for b in content {
if let AssistantContentBlock::ToolCall { id, name, .. } = b {
tool_names.insert(id.clone(), name.clone());
}
}
let blocks: Vec<ContentBlock> = content
.iter()
.filter_map(|b| match b {
AssistantContentBlock::Text { text } => {
Some(ContentBlock::Text(TextContent::new(text.clone())))
}
AssistantContentBlock::Thinking { thinking } => Some(
ContentBlock::Thinking(ThinkingContent::new(thinking.clone())),
),
AssistantContentBlock::ToolCall {
id,
name,
arguments,
} => Some(ContentBlock::ToolCall(ToolCall::new(
id.clone(),
name.clone(),
arguments.clone(),
))),
AssistantContentBlock::ImageResult { data, media_type } => {
Some(ContentBlock::Image(ImageContent::new(
data.clone(),
media_type.clone(),
)))
}
_ => None,
})
.collect();
if blocks.is_empty() {
continue;
}
let api = provider
.as_ref()
.and_then(|p| oxi_sdk::get_provider_api(p))
.unwrap_or(oxi_ai::Api::OpenAiCompletions);
let mut am = AssistantMessage::new(
api,
provider.clone().unwrap_or_else(|| "assistant".to_string()),
model_id.clone().unwrap_or_else(|| "assistant".to_string()),
);
am.content = blocks;
out.push(Message::Assistant(am));
}
AgentMessage::ToolResult {
content,
tool_call_id,
} => {
let tool_name = tool_names
.get(tool_call_id)
.cloned()
.unwrap_or_else(|| "tool".to_string());
out.push(Message::tool_result(
tool_call_id.clone(),
tool_name,
vec![ContentBlock::Text(TextContent::new(
content.as_str().to_string(),
))],
));
}
_ => {}
}
}
out
}
#[allow(dead_code)]
impl AgentSession {
pub fn new(
agent: Arc<Agent>,
settings: Settings,
session_manager: SessionManager,
cwd: String,
) -> Self {
let session_id = session_manager.get_session_id();
let history = resume_messages_from_branch(&session_manager.get_branch(None));
if !history.is_empty() {
agent.update_state(|s| s.messages = history);
}
let compaction_config = CompactionConfig {
enabled: settings.auto_compaction,
..CompactionConfig::default()
};
Self {
agent,
settings: Arc::new(RwLock::new(settings)),
session_manager: Arc::new(RwLock::new(session_manager)),
listeners: Arc::new(RwLock::new(Vec::new())),
scoped_models: Arc::new(RwLock::new(Vec::new())),
steering_messages: Arc::new(RwLock::new(VecDeque::new())),
follow_up_messages: Arc::new(RwLock::new(VecDeque::new())),
compaction_config: Arc::new(RwLock::new(compaction_config)),
compaction_abort: Arc::new(Mutex::new(None)),
overflow_recovery_attempted: Arc::new(RwLock::new(false)),
session_id: Arc::new(RwLock::new(session_id)),
cwd,
streaming: Arc::new(AtomicBool::new(false)),
should_stop: Arc::new(AtomicBool::new(false)),
extension_runner: Arc::new(RwLock::new(None)),
advisor: Arc::new(RwLock::new(None)),
advisor_guard: Arc::new(AdvisorEmissionGuard::new()),
advisor_delivery: Arc::new(PlMutex::new(AdvisorDeliveryState::default())),
advisor_primary_turns: Arc::new(std::sync::atomic::AtomicU64::new(0)),
}
}
pub fn model_id(&self) -> String {
self.agent.model_id()
}
#[allow(dead_code)]
pub fn state(&self) -> AgentState {
self.agent.state()
}
pub fn thinking_level(&self) -> ThinkingLevel {
self.settings.read().thinking_level
}
#[allow(dead_code)]
pub fn is_streaming(&self) -> bool {
self.streaming.load(Ordering::SeqCst)
}
pub fn streaming_flag(&self) -> Arc<AtomicBool> {
Arc::clone(&self.streaming)
}
#[allow(dead_code)]
pub fn messages(&self) -> Vec<Message> {
self.agent.state().messages
}
pub fn session_id(&self) -> String {
self.session_manager.read().get_session_id()
}
#[allow(dead_code)]
pub fn is_compacting(&self) -> bool {
match self.compaction_abort.try_lock() {
Ok(guard) => guard.is_some(), Err(_) => true, }
}
#[allow(dead_code)]
pub fn auto_retry_enabled(&self) -> bool {
true
}
pub fn session_stats(&self) -> SessionStats {
let state = self.agent.state();
let mut user_messages = 0usize;
let mut assistant_messages = 0usize;
let mut tool_results = 0usize;
let mut tool_calls = 0usize;
for msg in &state.messages {
match msg {
Message::User(_) => user_messages += 1,
Message::Assistant(a) => {
assistant_messages += 1;
for block in &a.content {
if matches!(block, oxi_sdk::ContentBlock::ToolCall(_)) {
tool_calls += 1;
}
}
let _ = &a; }
Message::ToolResult(_) => tool_results += 1,
}
}
SessionStats {
session_id: self.session_id(),
user_messages,
assistant_messages,
tool_calls,
tool_results,
total_messages: state.messages.len(),
}
}
#[allow(dead_code)]
pub fn pending_message_count(&self) -> usize {
self.steering_messages.read().len() + self.follow_up_messages.read().len()
}
pub fn steering_messages(&self) -> Vec<String> {
self.steering_messages.read().iter().cloned().collect()
}
pub fn follow_up_messages(&self) -> Vec<String> {
self.follow_up_messages.read().iter().cloned().collect()
}
pub fn steering_queue(&self) -> Arc<RwLock<std::collections::VecDeque<String>>> {
self.steering_messages.clone()
}
pub fn follow_up_queue(&self) -> Arc<RwLock<std::collections::VecDeque<String>>> {
self.follow_up_messages.clone()
}
#[allow(dead_code)]
pub fn cwd(&self) -> &str {
&self.cwd
}
pub fn scoped_models(&self) -> Vec<ScopedModel> {
self.scoped_models.read().clone()
}
pub fn auto_compaction_enabled(&self) -> bool {
self.compaction_config.read().enabled
}
pub fn subscribe(
&self,
listener: Box<dyn Fn(&SessionEvent) + Send + Sync>,
) -> SessionListenerGuard {
let key = {
let mut listeners = self.listeners.write();
listeners.push(listener);
listeners.len() - 1
};
SessionListenerGuard {
listeners: Arc::clone(&self.listeners),
key,
}
}
fn emit(&self, event: SessionEvent) {
let listeners = self.listeners.read();
for listener in listeners.iter() {
listener(&event);
}
}
fn emit_queue_update(&self) {
self.emit(SessionEvent::QueueUpdate {
steering: self.steering_messages(),
follow_up: self.follow_up_messages(),
});
}
pub fn steer_sync(&self, text: String) {
{
let mut queue = self.steering_messages.write();
queue.push_back(text);
}
self.emit_queue_update();
}
#[allow(dead_code)]
pub async fn steer(&self, text: String) -> Result<()> {
self.steer_sync(text);
Ok(())
}
pub fn follow_up_sync(&self, text: String) {
{
let mut queue = self.follow_up_messages.write();
queue.push_back(text);
}
self.emit_queue_update();
}
#[allow(dead_code)]
pub async fn follow_up(&self, text: String) -> Result<()> {
self.follow_up_sync(text);
Ok(())
}
pub async fn abort(&self) {
tracing::debug!("AgentSession::abort() — setting should_stop flag");
self.should_stop.store(true, Ordering::SeqCst);
self.clear_queue();
}
pub fn should_stop_flag(&self) -> Arc<AtomicBool> {
Arc::clone(&self.should_stop)
}
pub fn reset_should_stop(&self) {
self.should_stop.store(false, Ordering::SeqCst);
}
pub fn clear_queue(&self) -> (Vec<String>, Vec<String>) {
let steering: Vec<String> = self.steering_messages.write().drain(..).collect();
let follow_up: Vec<String> = self.follow_up_messages.write().drain(..).collect();
self.emit_queue_update();
(steering, follow_up)
}
pub fn refresh_api_key(&self) -> Result<()> {
self.agent.refresh_credentials()?;
Ok(())
}
pub fn set_model(&self, model_id: &str) -> Result<()> {
self.agent.switch_model(model_id)?;
{
let mut sm = self.session_manager.write();
let parts: Vec<&str> = model_id.split('/').collect();
if parts.len() >= 2 {
sm.append_model_change(parts[0], &parts[1..].join("/"));
}
}
{
let mut settings = self.settings.write();
let parts: Vec<&str> = model_id.split('/').collect();
if parts.len() >= 2 {
settings.last_used_provider = Some(parts[0].to_string());
settings.last_used_model = Some(parts[1..].join("/"));
} else {
settings.last_used_model = Some(model_id.to_string());
}
}
Ok(())
}
pub fn set_scoped_models(&self, models: Vec<ScopedModel>) {
*self.scoped_models.write() = models;
}
pub fn set_thinking_level(&self, level: ThinkingLevel) {
let old_level = self.thinking_level();
if level == old_level {
return;
}
{
let mut settings = self.settings.write();
settings.thinking_level = level;
}
{
let mut sm = self.session_manager.write();
sm.append_thinking_level_change(&format!("{:?}", level).to_lowercase());
}
self.emit(SessionEvent::ThinkingLevelChanged { level });
}
pub fn cycle_thinking_level(&self) -> Option<ThinkingLevel> {
let levels = [
ThinkingLevel::Off,
ThinkingLevel::Minimal,
ThinkingLevel::Low,
ThinkingLevel::Medium,
ThinkingLevel::High,
ThinkingLevel::XHigh,
];
let current = self.thinking_level();
let current_index = levels.iter().position(|l| *l == current).unwrap_or(0);
let next_index = (current_index + 1) % levels.len();
let next = levels[next_index];
self.set_thinking_level(next);
Some(next)
}
pub fn rebuild_system_prompt(&self) {
let fresh = crate::store::settings::Settings::load().unwrap_or_default();
let thinking = fresh.thinking_level;
let languages = fresh.output_languages.clone();
let language_policy_enabled = fresh.language_policy_enabled;
let auto_compaction = fresh.auto_compaction;
*self.settings.write() = fresh;
self.compaction_config.write().enabled = auto_compaction;
let strategy = if auto_compaction {
oxi_sdk::CompactionStrategy::Threshold(0.8)
} else {
oxi_sdk::CompactionStrategy::Disabled
};
self.agent.set_compaction_strategy(strategy);
let prompt = crate::app::agent_session_runtime::build_system_prompt(
thinking,
language_policy_enabled,
&languages,
);
self.agent.set_system_prompt(prompt);
}
pub async fn compact(&self, custom_instructions: Option<String>) -> Result<CompactionResult> {
self.emit(SessionEvent::CompactionStart {
reason: CompactionReason::Manual,
});
let result = self.run_compaction(custom_instructions).await;
match &result {
Ok(_r) => self.emit(SessionEvent::CompactionEnd {
reason: CompactionReason::Manual,
error_message: None,
}),
Err(e) => self.emit(SessionEvent::CompactionEnd {
reason: CompactionReason::Manual,
error_message: Some(e.to_string()),
}),
}
result
}
async fn run_compaction(
&self,
custom_instructions: Option<String>,
) -> Result<CompactionResult> {
let state = self.agent.state();
let messages = state.messages.clone();
if messages.len() < 3 {
anyhow::bail!("Nothing to compact (session too small)");
}
let compacted = self
.agent
.compaction_manager()
.compact_now(&messages, custom_instructions.as_deref())
.await
.context("Compaction failed")?;
let tokens_before = state.estimate_tokens();
self.agent.update_state(|s| {
s.replace_messages(compacted.kept_messages.clone());
});
self.persist_session();
Ok(CompactionResult { tokens_before })
}
pub fn abort_compaction_sync(&self) {
if let Ok(mut guard) = self.compaction_abort.try_lock()
&& let Some(handle) = guard.take()
{
handle.abort();
}
}
fn persist_session(&self) {
let state = self.agent.state();
let messages = &state.messages;
let total = messages.len();
if total == 0 {
return;
}
let mut sm = self.session_manager.write();
let already_in_sm = sm.get_entries().len();
if already_in_sm >= total {
return; }
for msg in &messages[already_in_sm..] {
match msg {
Message::User(u) => {
let content = match &u.content {
oxi_sdk::MessageContent::Text(t) => t.clone(),
oxi_sdk::MessageContent::Blocks(blocks) => blocks
.iter()
.filter_map(|b| b.as_text())
.collect::<Vec<_>>()
.join(""),
};
sm.append_message(AgentMessage::User {
content: crate::store::session::ContentValue::String(content),
});
}
Message::Assistant(a) => {
let content_blocks: Vec<crate::store::session::AssistantContentBlock> = a
.content
.iter()
.map(|b| match b {
oxi_sdk::ContentBlock::Text(t) => {
crate::store::session::AssistantContentBlock::Text {
text: t.text.clone(),
}
}
oxi_sdk::ContentBlock::Thinking(t) => {
crate::store::session::AssistantContentBlock::Thinking {
thinking: t.thinking.clone(),
}
}
oxi_sdk::ContentBlock::ToolCall(tc) => {
crate::store::session::AssistantContentBlock::ToolCall {
id: tc.id.clone(),
name: tc.name.clone(),
arguments: tc.arguments.clone(),
}
}
oxi_sdk::ContentBlock::Image(img) => {
crate::store::session::AssistantContentBlock::ImageResult {
data: img.data.clone(),
media_type: img.mime_type.clone(),
}
}
oxi_sdk::ContentBlock::Unknown(v) => {
crate::store::session::AssistantContentBlock::Text {
text: v.to_string(),
}
}
})
.collect();
sm.append_message(AgentMessage::Assistant {
content: content_blocks,
provider: Some(a.provider.clone()),
model_id: Some(a.model.clone()),
usage: Some(crate::store::session::Usage {
input: Some(a.usage.input as i64),
output: Some(a.usage.output as i64),
cache_read: Some(a.usage.cache_read as i64),
cache_write: Some(a.usage.cache_write as i64),
total_tokens: Some(a.usage.total_tokens as i64),
}),
stop_reason: Some(format!("{:?}", a.stop_reason)),
});
}
Message::ToolResult(t) => {
let content = t
.content
.iter()
.filter_map(|b| b.as_text())
.collect::<Vec<_>>()
.join("");
sm.append_message(AgentMessage::ToolResult {
content: crate::store::session::ContentValue::String(content),
tool_call_id: t.tool_call_id.clone(),
});
}
}
}
sm.set_persisted_count(total);
}
pub fn set_session_name(&self, name: String) {
let mut sm = self.session_manager.write();
sm.append_session_info(&name);
self.emit(SessionEvent::SessionInfoChanged);
}
pub fn reset(&self) {
self.agent.reset();
*self.overflow_recovery_attempted.write() = false;
self.clear_queue();
}
pub fn cleanup_empty_session(&self) {
self.session_manager.read().cleanup_if_empty();
}
pub fn agent_ref(&self) -> Arc<Agent> {
Arc::clone(&self.agent)
}
pub fn persist_user_message(&self, content: String) {
let mut sm = self.session_manager.write();
sm.append_message(AgentMessage::User {
content: crate::store::session::ContentValue::String(content),
});
let count = sm.persisted_count();
sm.set_persisted_count(count + 1);
}
pub fn persist_event_message(&self, message: &oxi_sdk::Message) {
let mut sm = self.session_manager.write();
match message {
Message::User(u) => {
let content = match &u.content {
oxi_sdk::MessageContent::Text(t) => t.clone(),
oxi_sdk::MessageContent::Blocks(blocks) => blocks
.iter()
.filter_map(|b| b.as_text())
.collect::<Vec<_>>()
.join(""),
};
sm.append_message(AgentMessage::User {
content: crate::store::session::ContentValue::String(content),
});
}
Message::Assistant(a) => {
let content_blocks: Vec<crate::store::session::AssistantContentBlock> = a
.content
.iter()
.map(|b| match b {
oxi_sdk::ContentBlock::Text(t) => {
crate::store::session::AssistantContentBlock::Text {
text: t.text.clone(),
}
}
oxi_sdk::ContentBlock::Thinking(t) => {
crate::store::session::AssistantContentBlock::Thinking {
thinking: t.thinking.clone(),
}
}
oxi_sdk::ContentBlock::ToolCall(tc) => {
crate::store::session::AssistantContentBlock::ToolCall {
id: tc.id.clone(),
name: tc.name.clone(),
arguments: tc.arguments.clone(),
}
}
oxi_sdk::ContentBlock::Image(img) => {
crate::store::session::AssistantContentBlock::ImageResult {
data: img.data.clone(),
media_type: img.mime_type.clone(),
}
}
oxi_sdk::ContentBlock::Unknown(v) => {
crate::store::session::AssistantContentBlock::Text {
text: v.to_string(),
}
}
})
.collect();
sm.append_message(AgentMessage::Assistant {
content: content_blocks,
provider: Some(a.provider.clone()),
model_id: Some(a.model.clone()),
usage: Some(crate::store::session::Usage {
input: Some(a.usage.input as i64),
output: Some(a.usage.output as i64),
cache_read: Some(a.usage.cache_read as i64),
cache_write: Some(a.usage.cache_write as i64),
total_tokens: Some(a.usage.total_tokens as i64),
}),
stop_reason: Some(format!("{:?}", a.stop_reason)),
});
}
Message::ToolResult(t) => {
let content = t
.content
.iter()
.filter_map(|b| b.as_text())
.collect::<Vec<_>>()
.join("");
sm.append_message(AgentMessage::ToolResult {
content: crate::store::session::ContentValue::String(content),
tool_call_id: t.tool_call_id.clone(),
});
}
}
let count = sm.persisted_count();
sm.set_persisted_count(count + 1);
}
pub fn persist(&self) {
self.persist_session();
}
pub fn clone_handle(&self) -> AgentSessionHandle {
AgentSessionHandle {
inner: Arc::new(self.clone_inner()),
}
}
fn clone_inner(&self) -> Self {
Self {
agent: Arc::clone(&self.agent),
settings: Arc::clone(&self.settings),
session_manager: Arc::clone(&self.session_manager),
listeners: Arc::clone(&self.listeners),
scoped_models: Arc::clone(&self.scoped_models),
steering_messages: Arc::clone(&self.steering_messages),
follow_up_messages: Arc::clone(&self.follow_up_messages),
compaction_config: Arc::clone(&self.compaction_config),
compaction_abort: Arc::clone(&self.compaction_abort),
overflow_recovery_attempted: Arc::clone(&self.overflow_recovery_attempted),
session_id: Arc::clone(&self.session_id),
cwd: self.cwd.clone(),
streaming: Arc::clone(&self.streaming),
should_stop: Arc::clone(&self.should_stop),
extension_runner: Arc::clone(&self.extension_runner),
advisor: Arc::clone(&self.advisor),
advisor_guard: Arc::clone(&self.advisor_guard),
advisor_delivery: Arc::clone(&self.advisor_delivery),
advisor_primary_turns: Arc::clone(&self.advisor_primary_turns),
}
}
pub fn set_extension_runner(&self, runner: ExtensionRunner) {
{
let guard = self.extension_runner.read();
if let Some(existing) = guard.as_ref() {
let session_id = self.session_id();
let shutdown_event = SessionShutdownEvent {
reason: SessionShutdownReason::Reload,
target_session_file: None,
};
existing.emit_session_shutdown_event(&shutdown_event);
existing.registry().emit_session_end(&session_id);
existing.registry().emit_unload();
}
}
{
let mut guard = self.extension_runner.write();
*guard = Some(runner);
}
{
let guard = self.extension_runner.read();
if let Some(runner) = guard.as_ref() {
let ctx = self.build_extension_context();
runner.registry().emit_load(&ctx);
let session_id = self.session_id();
runner.registry().emit_session_start(&session_id);
}
}
tracing::debug!("ExtensionRunner installed into AgentSession");
}
pub fn extension_runner(&self) -> parking_lot::RwLockReadGuard<'_, Option<ExtensionRunner>> {
self.extension_runner.read()
}
pub fn take_extension_runner(&self) -> Option<ExtensionRunner> {
{
let guard = self.extension_runner.read();
if let Some(runner) = guard.as_ref() {
let session_id = self.session_id();
let shutdown_event = SessionShutdownEvent {
reason: SessionShutdownReason::Quit,
target_session_file: None,
};
runner.emit_session_shutdown_event(&shutdown_event);
runner.registry().emit_session_end(&session_id);
runner.registry().emit_unload();
}
}
self.extension_runner.write().take()
}
pub fn build_extension_context(&self) -> ExtensionContext {
ExtensionContextBuilder::new(PathBuf::from(&self.cwd))
.settings(Arc::clone(&self.settings))
.build()
}
pub fn forward_event_to_extensions(&self, event: &AgentEvent) {
if let AgentEvent::TurnEnd { .. } = event {
self.on_advisor_turn_end();
}
self.emit(SessionEvent::Agent(Box::new(event.clone())));
let guard = self.extension_runner.read();
if let Some(runner) = guard.as_ref() {
runner.registry().emit_event(event);
match event {
AgentEvent::ToolCall { tool_call } => {
runner.emit_tool_call(&tool_call.name, &tool_call.arguments);
}
AgentEvent::ToolExecutionStart {
tool_name, args, ..
} => {
runner.emit_tool_call(tool_name, args);
}
AgentEvent::ToolExecutionEnd {
tool_name, result, ..
} => {
let tool_result = oxi_agent::AgentToolResult::success(&result.content);
runner.emit_tool_result_event(tool_name, &tool_result);
}
_ => {}
}
}
}
pub fn has_extension_handlers(&self, event_type: &str) -> bool {
let guard = self.extension_runner.read();
if let Some(runner) = guard.as_ref() {
runner.has_handlers(event_type)
} else {
false
}
}
pub fn extension_tools(&self) -> Vec<Arc<dyn oxi_agent::AgentTool>> {
let guard = self.extension_runner.read();
if let Some(runner) = guard.as_ref() {
runner.all_tools()
} else {
Vec::new()
}
}
pub fn extension_commands(&self) -> Vec<crate::extensions::Command> {
let guard = self.extension_runner.read();
if let Some(runner) = guard.as_ref() {
runner.all_commands()
} else {
Vec::new()
}
}
pub fn emit_before_tool_call(
&self,
tool_name: &str,
params: &serde_json::Value,
) -> crate::extensions::ToolCallEmitResult {
let guard = self.extension_runner.read();
if let Some(runner) = guard.as_ref() {
runner.emit_tool_call(tool_name, params)
} else {
crate::extensions::ToolCallEmitResult::default()
}
}
pub fn emit_after_tool_result(
&self,
tool_name: &str,
result: &oxi_agent::AgentToolResult,
) -> crate::extensions::ToolResultEmitResult {
let guard = self.extension_runner.read();
if let Some(runner) = guard.as_ref() {
runner.emit_tool_result_event(tool_name, result)
} else {
crate::extensions::ToolResultEmitResult::default()
}
}
pub fn notify_extensions_settings_changed(&self) {
let guard = self.extension_runner.read();
if let Some(runner) = guard.as_ref() {
let settings = self.settings.read().clone();
runner.registry().emit_settings_changed(&settings);
}
}
#[must_use]
pub fn is_advisor_enabled(&self) -> bool {
self.advisor.read().is_some()
}
pub fn set_advisor_enabled(&self, enabled: bool) -> Result<bool> {
{
let mut s = self.settings.write();
s.advisor.enabled = enabled;
}
if enabled {
if self.advisor.read().is_none() {
let rt = self.build_advisor().ok_or_else(|| {
anyhow::anyhow!(
"advisor could not start: no provider/model resolved for the \
advisor role or the primary model"
)
})?;
*self.advisor.write() = Some(rt);
}
Ok(true)
} else {
if let Some(rt) = self.advisor.write().take() {
rt.dispose();
}
Ok(false)
}
}
pub fn toggle_advisor(&self) -> Result<bool> {
self.set_advisor_enabled(!self.is_advisor_enabled())
}
#[must_use]
pub fn advisor_status(&self) -> String {
let enabled = self.is_advisor_enabled();
let backlog = self.advisor.read().as_ref().map_or(0u64, |rt| rt.backlog());
let turns = self.advisor_primary_turns.load(Ordering::SeqCst);
format!(
"advisor: {} | backlog {} turn(s) behind | {} primary turn(s) observed",
if enabled { "ON" } else { "OFF" },
backlog,
turns
)
}
pub fn reset_advisor_state(&self) {
self.advisor_guard.reset();
*self.advisor_delivery.lock() = AdvisorDeliveryState::default();
self.advisor_primary_turns.store(0, Ordering::SeqCst);
if let Some(rt) = self.advisor.read().as_ref() {
rt.reset();
}
}
fn on_advisor_turn_end(&self) {
self.advisor_primary_turns.fetch_add(1, Ordering::SeqCst);
let rt = self.advisor.read().clone();
let Some(rt) = rt else {
return;
};
let messages = self.agent_ref().state().messages;
rt.on_turn_end(messages);
let sync = self.settings.read().advisor.sync_backlog.clone();
if sync != "off"
&& let Ok(threshold) = sync.parse::<u64>()
&& threshold > 0
{
let rt = Arc::clone(&rt);
tokio::spawn(async move {
let _ = rt
.wait_for_catchup(std::time::Duration::from_millis(30_000), threshold)
.await;
});
}
}
fn build_advisor(&self) -> Option<Arc<AdvisorRuntime>> {
let settings = self.settings.read();
let primary_model = self.agent.model_id();
let advisor_model_id = oxi_ai::roles::live_role_registry()
.and_then(|r| {
let reg = r.read();
resolve_role_to_model(ModelRole::Advisor, ®)
})
.map(|m| format!("{}/{}", m.provider, m.id))
.unwrap_or_else(|| primary_model.clone());
let (provider_name, _model_name) = advisor_model_id.split_once('/')?;
let provider_box = get_provider(provider_name)?;
let base: Arc<dyn Provider> = Arc::from(provider_box);
let registry = oxi_ai::roles::live_role_registry()
.map(std::sync::Arc::clone)
.unwrap_or_else(|| {
std::sync::Arc::new(parking_lot::RwLock::new(RoleRegistry::default()))
});
let provider: Arc<dyn Provider> = Arc::new(RoleRoutingProvider::new(base, registry));
let config = AgentConfig {
name: "advisor".to_string(),
description: Some("oxi read-only advisor".to_string()),
model_id: advisor_model_id,
system_prompt: Some(crate::app::advisor_context::assemble_advisor_system_prompt(
&self.cwd,
)),
timeout_seconds: settings.tool_timeout_seconds,
temperature: settings.effective_temperature(),
max_tokens: settings.effective_max_tokens(),
compaction_strategy: CompactionStrategy::Threshold(0.8),
compaction_instruction: None,
context_window: 128_000,
workspace_dir: Some(std::path::PathBuf::from(&self.cwd)),
output_mode: None,
session_id: None,
provider_options: None,
ttsr_engine: None,
memory: None,
todo: None,
agent_pool: None,
..Default::default()
};
let immune_turns = settings.advisor.immune_turns;
drop(settings);
let advisor_agent = Arc::new(Agent::new(provider, config, Arc::new(ToolRegistry::new())));
let tools = advisor_agent.tools();
tools.register(ReadTool::new());
tools.register(GrepTool::new());
tools.register(FindTool::new());
let enqueue: EnqueueAdviceFn = {
let steering = Arc::clone(&self.steering_messages);
let listeners = Arc::clone(&self.listeners);
let guard = Arc::clone(&self.advisor_guard);
let delivery = Arc::clone(&self.advisor_delivery);
let streaming = Arc::clone(&self.streaming);
let primary_turns = Arc::clone(&self.advisor_primary_turns);
Arc::new(move |note: AdvisorNote| {
if !guard.accept(¬e.note) {
return;
}
let mut d = *delivery.lock();
let turns = primary_turns.load(Ordering::SeqCst);
let immune = oxi_agent::advisor::is_immune_turn_active(
turns,
d.interrupt_immune_turn_start,
immune_turns,
);
let channel = resolve_delivery_channel(DeliveryOpts {
severity: note.severity,
auto_resume_suppressed: d.auto_resume_suppressed,
streaming: streaming.load(Ordering::SeqCst),
aborting: false,
interrupt_immune_turn_active: immune,
});
let body = format_advisory_batch(std::slice::from_ref(¬e));
match channel {
AdvisorDeliveryChannel::Steer => {
steering.write().push_back(body);
d.interrupt_immune_turn_start = Some(turns + 1);
*delivery.lock() = d;
}
AdvisorDeliveryChannel::Aside | AdvisorDeliveryChannel::Preserve => {
let evt = SessionEvent::Advisor { channel, body };
for f in listeners.read().iter() {
f(&evt);
}
}
}
})
};
tools.register(AdviseTool::new(enqueue));
let host: Arc<dyn AdvisorRuntimeHost> = Arc::new(AdvisorHost {
agent: self.agent_ref(), guard: Arc::clone(&self.advisor_guard),
});
let recorder = Arc::new(crate::app::advisor_context::AdvisorTranscriptRecorder::new(
self.session_manager.read().get_session_file(),
));
let advisor_driver = Arc::new(AgentAdvisor::with_post_prompt_hook(
advisor_agent,
recorder.hook(),
));
let rt = Arc::new(AdvisorRuntime::new(
advisor_driver,
host,
std::time::Duration::from_millis(1000),
));
rt.install_self(Arc::downgrade(&rt));
rt.seed_to(self.agent_ref().state().messages.len() as u64);
Some(rt)
}
}
pub struct SessionListenerGuard {
#[allow(clippy::type_complexity)]
listeners: Arc<RwLock<Vec<Box<dyn Fn(&SessionEvent) + Send + Sync>>>>,
key: usize,
}
impl Drop for SessionListenerGuard {
fn drop(&mut self) {
let mut listeners = self.listeners.write();
if self.key < listeners.len() {
listeners[self.key] = Box::new(|_| {});
}
}
}
#[derive(Clone)]
pub struct AgentSessionHandle {
inner: Arc<AgentSession>,
}
impl std::ops::Deref for AgentSessionHandle {
type Target = AgentSession;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::Stream;
use oxi_agent::AgentConfig;
use oxi_sdk::{Model, Provider, ProviderError, ProviderEvent};
use std::future::Future;
use std::pin::Pin;
use std::task::{Context as TaskContext, Poll};
struct MockProvider;
struct EmptyStream;
impl Stream for EmptyStream {
type Item = ProviderEvent;
fn poll_next(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
Poll::Ready(None)
}
}
impl Provider for MockProvider {
fn stream<'a>(
&'a self,
_model: &'a Model,
_context: &'a oxi_sdk::Context,
_options: Option<oxi_sdk::StreamOptions>,
) -> Pin<
Box<
dyn Future<
Output = Result<
Pin<Box<dyn Stream<Item = ProviderEvent> + Send>>,
ProviderError,
>,
> + Send
+ 'a,
>,
> {
Box::pin(async move {
Ok::<_, ProviderError>(
Box::pin(EmptyStream) as Pin<Box<dyn Stream<Item = ProviderEvent> + Send>>
)
})
}
fn name(&self) -> &str {
"mock"
}
}
fn make_session() -> AgentSession {
let provider = Arc::new(MockProvider);
let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
let agent = Arc::new(Agent::new(
provider,
config,
Arc::new(oxi_agent::ToolRegistry::new()),
));
let settings = Settings::default();
let session_manager = SessionManager::in_memory("/tmp/test");
AgentSession::new(agent, settings, session_manager, "/tmp/test".to_string())
}
#[test]
fn test_session_creation_basic_fields() {
let session = make_session();
assert!(!session.session_id().is_empty());
assert_eq!(session.cwd(), "/tmp/test");
assert!(!session.is_streaming());
assert!(session.messages().is_empty());
}
#[test]
fn test_session_creation_model_id() {
let session = make_session();
assert_eq!(session.model_id(), "anthropic/claude-sonnet-4-20250514");
}
#[test]
fn test_session_creation_default_thinking_level() {
let session = make_session();
assert_eq!(session.thinking_level(), ThinkingLevel::Medium);
}
#[test]
fn test_session_creation_empty_queues() {
let session = make_session();
assert_eq!(session.pending_message_count(), 0);
assert!(session.steering_messages().is_empty());
assert!(session.follow_up_messages().is_empty());
}
#[test]
fn test_scoped_models_empty_by_default() {
let session = make_session();
assert!(session.scoped_models().is_empty());
}
#[test]
fn test_set_scoped_models() {
let session = make_session();
let models = vec![
ScopedModel {
provider: "anthropic".to_string(),
model_id: "claude-sonnet-4-20250514".to_string(),
},
ScopedModel {
provider: "openai".to_string(),
model_id: "gpt-4o".to_string(),
},
ScopedModel {
provider: "google".to_string(),
model_id: "gemini-2.0-flash".to_string(),
},
];
session.set_scoped_models(models);
let retrieved = session.scoped_models();
assert_eq!(retrieved.len(), 3);
assert_eq!(retrieved[0].provider, "anthropic");
assert_eq!(retrieved[2].model_id, "gemini-2.0-flash");
}
#[test]
fn test_scoped_model_fields() {
let model = ScopedModel {
provider: "anthropic".to_string(),
model_id: "claude-sonnet-4-20250514".to_string(),
};
assert_eq!(model.provider, "anthropic");
assert_eq!(model.model_id, "claude-sonnet-4-20250514");
}
#[test]
fn test_set_thinking_level() {
let session = make_session();
assert_eq!(session.thinking_level(), ThinkingLevel::Medium);
session.set_thinking_level(ThinkingLevel::High);
assert_eq!(session.thinking_level(), ThinkingLevel::High);
session.set_thinking_level(ThinkingLevel::Off);
assert_eq!(session.thinking_level(), ThinkingLevel::Off);
session.set_thinking_level(ThinkingLevel::Minimal);
assert_eq!(session.thinking_level(), ThinkingLevel::Minimal);
}
#[test]
fn test_set_thinking_level_noop_when_same() {
let session = make_session();
session.set_thinking_level(ThinkingLevel::Medium);
assert_eq!(session.thinking_level(), ThinkingLevel::Medium);
}
#[test]
fn test_cycle_thinking_level() {
let session = make_session();
assert_eq!(session.thinking_level(), ThinkingLevel::Medium);
let next = session.cycle_thinking_level();
assert_eq!(next, Some(ThinkingLevel::High));
assert_eq!(session.thinking_level(), ThinkingLevel::High);
let next = session.cycle_thinking_level();
assert_eq!(next, Some(ThinkingLevel::XHigh));
assert_eq!(session.thinking_level(), ThinkingLevel::XHigh);
let next = session.cycle_thinking_level();
assert_eq!(next, Some(ThinkingLevel::Off));
let next = session.cycle_thinking_level();
assert_eq!(next, Some(ThinkingLevel::Minimal));
let next = session.cycle_thinking_level();
assert_eq!(next, Some(ThinkingLevel::Low));
let next = session.cycle_thinking_level();
assert_eq!(next, Some(ThinkingLevel::Medium));
let next = session.cycle_thinking_level();
assert_eq!(next, Some(ThinkingLevel::High));
let next = session.cycle_thinking_level();
assert_eq!(next, Some(ThinkingLevel::XHigh));
}
#[test]
fn test_thinking_level_full_cycle() {
let levels = [
ThinkingLevel::Off,
ThinkingLevel::Minimal,
ThinkingLevel::Low,
ThinkingLevel::Medium,
ThinkingLevel::High,
ThinkingLevel::XHigh,
];
let mut current = 0;
for _ in 0..levels.len() {
current = (current + 1) % levels.len();
}
assert_eq!(current, 0); }
#[tokio::test]
async fn test_steer_message() {
let session = make_session();
session.steer("direction 1".to_string()).await.unwrap();
assert_eq!(session.steering_messages(), vec!["direction 1"]);
assert_eq!(session.pending_message_count(), 1);
}
#[tokio::test]
async fn test_follow_up_message() {
let session = make_session();
session.follow_up("next task".to_string()).await.unwrap();
assert_eq!(session.follow_up_messages(), vec!["next task"]);
assert_eq!(session.pending_message_count(), 1);
}
#[tokio::test]
async fn test_multiple_steer_messages() {
let session = make_session();
session.steer("first".to_string()).await.unwrap();
session.steer("second".to_string()).await.unwrap();
session.steer("third".to_string()).await.unwrap();
assert_eq!(
session.steering_messages(),
vec!["first", "second", "third"]
);
assert_eq!(session.pending_message_count(), 3);
}
#[tokio::test]
async fn test_multiple_follow_up_messages() {
let session = make_session();
session.follow_up("a".to_string()).await.unwrap();
session.follow_up("b".to_string()).await.unwrap();
assert_eq!(session.follow_up_messages(), vec!["a", "b"]);
}
#[tokio::test]
async fn test_mixed_steer_and_follow_up() {
let session = make_session();
session.steer("steer-1".to_string()).await.unwrap();
session.follow_up("follow-1".to_string()).await.unwrap();
session.steer("steer-2".to_string()).await.unwrap();
assert_eq!(session.pending_message_count(), 3);
assert_eq!(session.steering_messages(), vec!["steer-1", "steer-2"]);
assert_eq!(session.follow_up_messages(), vec!["follow-1"]);
}
#[test]
fn test_clear_queue() {
let session = make_session();
{
let mut q = session.steering_messages.write();
q.push_back("s1".to_string());
q.push_back("s2".to_string());
}
{
let mut q = session.follow_up_messages.write();
q.push_back("f1".to_string());
}
assert_eq!(session.pending_message_count(), 3);
let (steering, follow_up) = session.clear_queue();
assert_eq!(steering, vec!["s1", "s2"]);
assert_eq!(follow_up, vec!["f1"]);
assert_eq!(session.pending_message_count(), 0);
}
#[test]
fn test_clear_empty_queue() {
let session = make_session();
let (s, f) = session.clear_queue();
assert!(s.is_empty());
assert!(f.is_empty());
}
#[test]
fn test_auto_compaction_default_enabled() {
let session = make_session();
assert!(session.auto_compaction_enabled());
}
#[test]
fn test_is_compacting_initially_false() {
let session = make_session();
assert!(!session.is_compacting());
}
#[test]
fn test_compaction_reason_variants() {
assert_eq!(CompactionReason::Manual, CompactionReason::Manual);
assert_ne!(CompactionReason::Manual, CompactionReason::Threshold);
assert_ne!(CompactionReason::Threshold, CompactionReason::Overflow);
assert_ne!(CompactionReason::Manual, CompactionReason::Overflow);
}
#[test]
fn test_compaction_config_default() {
let config = CompactionConfig::default();
assert!(config.enabled);
assert!(config.threshold > 0.0);
}
#[test]
fn test_session_stats_empty() {
let session = make_session();
let stats = session.session_stats();
assert!(!stats.session_id.is_empty());
assert_eq!(stats.user_messages, 0);
assert_eq!(stats.assistant_messages, 0);
assert_eq!(stats.tool_calls, 0);
assert_eq!(stats.tool_results, 0);
assert_eq!(stats.total_messages, 0);
}
#[test]
fn test_session_stats_default() {
let stats = SessionStats {
session_id: "test".to_string(),
user_messages: 0,
assistant_messages: 0,
tool_calls: 0,
tool_results: 0,
total_messages: 0,
};
assert_eq!(stats.total_messages, 0);
}
#[test]
fn test_persist_session_empty_messages() {
let session = make_session();
session.persist_session();
}
#[test]
fn test_persist_session_empty_is_noop() {
let session = make_session();
session.persist_session();
let sm = session.session_manager.read();
assert_eq!(sm.persisted_count(), 0);
}
#[test]
fn test_persist_session_set_persisted_count() {
let session = make_session();
{
let sm = session.session_manager.write();
sm.set_persisted_count(5);
}
let sm = session.session_manager.read();
assert_eq!(sm.persisted_count(), 5);
}
#[test]
fn test_persist_session_idempotent_with_set() {
let session = make_session();
{
let sm = session.session_manager.write();
sm.set_persisted_count(3);
}
session.persist_session();
let sm = session.session_manager.read();
assert_eq!(sm.persisted_count(), 3);
}
#[test]
fn test_set_session_name() {
let session = make_session();
session.set_session_name("My Test Session".to_string());
assert!(!session.session_id().is_empty());
}
#[test]
fn test_subscribe_receives_events() {
let session = make_session();
let received = Arc::new(RwLock::new(Vec::new()));
let received_clone = received.clone();
let _guard = session.subscribe(Box::new(move |event| {
received_clone.write().push(format!("{:?}", event));
}));
session.set_thinking_level(ThinkingLevel::High);
let events = received.read();
assert!(
!events.is_empty(),
"Listener should receive at least one event"
);
assert!(events.iter().any(|e| e.contains("ThinkingLevelChanged")));
}
#[test]
fn test_subscribe_channel_with_guard() {
let session = make_session();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
let _guard = session.subscribe(Box::new(move |event| {
let _ = tx.send(event.clone());
}));
session.set_thinking_level(ThinkingLevel::Off);
let event = rx
.try_recv()
.expect("Should receive event via subscribed channel");
match event {
SessionEvent::ThinkingLevelChanged { level } => {
assert_eq!(level, ThinkingLevel::Off);
}
other => panic!("Expected ThinkingLevelChanged, got {:?}", other),
}
}
#[test]
fn test_reset_clears_queues_and_overflow() {
let session = make_session();
{
let mut q = session.steering_messages.write();
q.push_back("steer".to_string());
}
{
let mut q = session.follow_up_messages.write();
q.push_back("follow".to_string());
}
*session.overflow_recovery_attempted.write() = true;
assert_eq!(session.pending_message_count(), 2);
session.reset();
assert_eq!(session.pending_message_count(), 0);
assert!(!*session.overflow_recovery_attempted.read());
}
#[test]
fn test_clone_handle_shares_state() {
let session = make_session();
let handle = session.clone_handle();
assert_eq!(session.session_id(), handle.session_id());
handle.set_thinking_level(ThinkingLevel::High);
assert_eq!(session.thinking_level(), ThinkingLevel::High);
}
#[test]
fn test_no_extension_runner_by_default() {
let session = make_session();
let guard = session.extension_runner();
assert!(guard.is_none());
}
#[test]
fn test_extension_tools_empty_without_runner() {
let session = make_session();
assert!(session.extension_tools().is_empty());
}
#[test]
fn test_extension_commands_empty_without_runner() {
let session = make_session();
assert!(session.extension_commands().is_empty());
}
#[test]
fn test_has_extension_handlers_false_without_runner() {
let session = make_session();
assert!(!session.has_extension_handlers("tool_call"));
}
#[test]
fn test_auto_retry_enabled() {
let session = make_session();
assert!(session.auto_retry_enabled());
}
#[test]
fn test_listener_guard_drop_removes() {
let session = make_session();
let received = Arc::new(RwLock::new(Vec::new()));
let received_clone = received.clone();
{
let _guard = session.subscribe(Box::new(move |event| {
received_clone.write().push(format!("{:?}", event));
}));
session.set_thinking_level(ThinkingLevel::High);
}
let count_after_drop = received.read().len();
assert_eq!(count_after_drop, 1); }
use crate::store::session::{AssistantContentBlock, ContentValue, SessionEntry};
fn entry(message: AgentMessage) -> SessionEntry {
SessionEntry::new(message)
}
#[test]
fn resume_reconstructs_tool_call_and_result() {
let branch = vec![
entry(AgentMessage::User {
content: ContentValue::String("list files".to_string()),
}),
entry(AgentMessage::Assistant {
content: vec![
AssistantContentBlock::Text {
text: "Running ls".to_string(),
},
AssistantContentBlock::ToolCall {
id: "call_1".to_string(),
name: "ls".to_string(),
arguments: serde_json::json!({}),
},
],
provider: Some("anthropic".to_string()),
model_id: Some("claude-sonnet-4-20250514".to_string()),
usage: None,
stop_reason: None,
}),
entry(AgentMessage::ToolResult {
content: ContentValue::String("file_a\nfile_b".to_string()),
tool_call_id: "call_1".to_string(),
}),
];
let messages = resume_messages_from_branch(&branch);
assert_eq!(messages.len(), 3, "all three turns reconstructed");
assert!(matches!(messages[0], oxi_sdk::Message::User(_)));
let assistant = match &messages[1] {
oxi_sdk::Message::Assistant(a) => a,
_ => panic!("expected assistant message"),
};
assert_eq!(assistant.content.len(), 2);
assert!(assistant.content.iter().any(
|b| matches!(b, oxi_sdk::ContentBlock::ToolCall(tc) if tc.id == "call_1"
&& tc.name == "ls")
));
match &messages[2] {
oxi_sdk::Message::ToolResult(t) => {
assert_eq!(t.tool_call_id, "call_1");
assert_eq!(t.tool_name, "ls", "tool name resolved from the call");
assert!(t.content.iter().any(|b| matches!(
b,
oxi_sdk::ContentBlock::Text(t) if t.text.contains("file_a")
)));
}
_ => panic!("expected tool result message"),
}
}
#[test]
fn resume_skips_non_conversation_entries() {
let branch = vec![
entry(AgentMessage::System {
content: ContentValue::String("sys note".to_string()),
}),
entry(AgentMessage::User {
content: ContentValue::String("hello".to_string()),
}),
entry(AgentMessage::BashExecution {
command: "echo hi".to_string(),
output: "hi".to_string(),
exit_code: Some(0),
cancelled: false,
truncated: false,
full_output_path: None,
exclude_from_context: None,
timestamp: 0,
}),
];
let messages = resume_messages_from_branch(&branch);
assert_eq!(messages.len(), 1, "only the user turn remains");
assert!(matches!(messages[0], oxi_sdk::Message::User(_)));
}
#[test]
fn resume_honours_compaction_summary() {
let branch = vec![
entry(AgentMessage::User {
content: ContentValue::String("old prompt".to_string()),
}),
entry(AgentMessage::CompactionSummary {
summary: "We discussed X.".to_string(),
tokens_before: 1000,
timestamp: 0,
}),
entry(AgentMessage::User {
content: ContentValue::String("new prompt".to_string()),
}),
];
let messages = resume_messages_from_branch(&branch);
assert_eq!(messages.len(), 2);
let first_text = match &messages[0] {
oxi_sdk::Message::User(u) => match &u.content {
oxi_sdk::MessageContent::Text(t) => t.clone(),
_ => String::new(),
},
_ => panic!("expected summary as user message"),
};
assert!(first_text.contains("We discussed X."));
}
#[test]
fn resume_empty_branch_is_empty() {
let messages = resume_messages_from_branch(&[]);
assert!(messages.is_empty());
}
#[test]
fn new_seeds_agent_state_from_resumed_session() {
let mut sm = SessionManager::in_memory("/tmp/test");
sm.append_message(AgentMessage::User {
content: ContentValue::String("what is 2+2".to_string()),
});
sm.append_message(AgentMessage::Assistant {
content: vec![AssistantContentBlock::Text {
text: "4".to_string(),
}],
provider: Some("anthropic".to_string()),
model_id: Some("claude-sonnet-4-20250514".to_string()),
usage: None,
stop_reason: None,
});
let before_count = sm.get_entries().len();
let provider = Arc::new(MockProvider);
let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
let agent = Arc::new(Agent::new(
provider,
config,
Arc::new(oxi_agent::ToolRegistry::new()),
));
let session = AgentSession::new(agent, Settings::default(), sm, "/tmp/test".to_string());
let messages = session.agent_ref().state().messages;
assert_eq!(messages.len(), 2, "agent state seeded with prior history");
assert!(matches!(messages[0], oxi_sdk::Message::User(_)));
assert_eq!(
session.session_manager.read().get_entries().len(),
before_count
);
session.persist_session();
assert_eq!(
session.session_manager.read().get_entries().len(),
before_count,
"persist_session must not duplicate seeded history"
);
}
}