use std::collections::{BTreeMap, VecDeque};
use std::time::Instant;
use reqwest::Client;
use rust_mcp_schema::CreateMessageRequest;
use tokio_util::sync::CancellationToken;
use crate::api::{ChatMessage, ChatToolCall};
use crate::auth::AuthManager;
use crate::character::card::CharacterCard;
use crate::character::service::CharacterService;
use crate::core::config::data::Config;
#[cfg(test)]
use crate::core::config::data::{DEFAULT_REFINE_INSTRUCTIONS, DEFAULT_REFINE_PREFIX};
use crate::core::providers::{
resolve_env_session, resolve_session, ProviderResolutionError, ProviderSession,
ResolveSessionError,
};
use crate::ui::appearance::{detect_preferred_appearance, Appearance};
use crate::ui::builtin_themes::{find_builtin_theme, theme_spec_from_custom};
use crate::ui::theme::Theme;
use crate::utils::color::quantize_theme_for_current_terminal;
use crate::utils::logging::LoggingState;
use crate::utils::url::construct_api_url;
pub struct SessionContext {
pub client: Client,
pub model: String,
pub api_key: String,
pub base_url: String,
pub provider_name: String,
pub provider_display_name: String,
pub logging: LoggingState,
pub stream_cancel_token: Option<CancellationToken>,
pub current_stream_id: u64,
pub last_retry_time: Instant,
pub retrying_message_index: Option<usize>,
pub is_refining: bool,
pub original_refining_content: Option<String>,
pub last_refine_prompt: Option<String>,
pub refine_instructions: String,
pub refine_prefix: String,
pub startup_env_only: bool,
pub mcp_disabled: bool,
pub active_character: Option<CharacterCard>,
pub character_greeting_shown: bool,
pub has_received_assistant_message: bool,
pub tool_pipeline: ToolPipelineState,
pub mcp_init: McpInitState,
pub active_assistant_message_index: Option<usize>,
pub mcp_tools_enabled: bool,
pub mcp_tools_unsupported: bool,
}
#[derive(Default, Clone)]
pub struct ToolPipelineState {
pub pending_tool_calls: BTreeMap<u32, PendingToolCall>,
pub pending_tool_queue: VecDeque<ToolCallRequest>,
pub active_tool_request: Option<ToolCallRequest>,
pub pending_sampling_queue: VecDeque<McpSamplingRequest>,
pub active_sampling_request: Option<McpSamplingRequest>,
pub tool_call_records: Vec<ChatToolCall>,
pub tool_results: Vec<ChatMessage>,
pub tool_result_history: Vec<ToolResultRecord>,
pub tool_payload_history: Vec<ToolPayloadHistoryEntry>,
pub continuation_messages: Option<StreamContinuation>,
}
#[derive(Clone)]
pub struct StreamContinuation {
pub api_messages: Vec<ChatMessage>,
pub api_messages_base: Vec<ChatMessage>,
}
#[derive(Default)]
pub struct McpInitState {
pub in_progress: bool,
pub complete: bool,
pub deferred_message: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PendingToolCall {
pub id: Option<String>,
pub name: Option<String>,
pub arguments: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolResultStatus {
Success,
Error,
Denied,
Blocked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolFailureKind {
ToolError,
ToolCallFailure,
}
impl ToolFailureKind {
pub fn label(self) -> &'static str {
match self {
ToolFailureKind::ToolError => "tool error",
ToolFailureKind::ToolCallFailure => "tool call failure",
}
}
pub fn display(self) -> &'static str {
match self {
ToolFailureKind::ToolError => "Tool error",
ToolFailureKind::ToolCallFailure => "Tool call failure",
}
}
}
impl ToolResultStatus {
pub fn label(self) -> &'static str {
match self {
ToolResultStatus::Success => "success",
ToolResultStatus::Error => "failed",
ToolResultStatus::Denied => "denied",
ToolResultStatus::Blocked => "blocked",
}
}
pub fn display(self) -> &'static str {
match self {
ToolResultStatus::Success => "Success",
ToolResultStatus::Error => "Failed",
ToolResultStatus::Denied => "Denied",
ToolResultStatus::Blocked => "Blocked",
}
}
}
#[derive(Debug, Clone)]
pub struct ToolResultRecord {
pub tool_name: String,
pub server_name: Option<String>,
pub server_id: Option<String>,
pub status: ToolResultStatus,
pub failure_kind: Option<ToolFailureKind>,
pub content: String,
pub summary: String,
pub tool_call_id: Option<String>,
pub raw_arguments: Option<String>,
pub assistant_message_index: Option<usize>,
}
#[derive(Clone)]
pub struct ToolPayloadHistoryEntry {
pub server_id: Option<String>,
pub tool_call_id: Option<String>,
pub assistant_message: ChatMessage,
pub tool_message: ChatMessage,
pub assistant_message_index: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct ToolCallRequest {
pub server_id: String,
pub tool_name: String,
pub arguments: Option<serde_json::Map<String, serde_json::Value>>,
pub raw_arguments: String,
pub tool_call_id: Option<String>,
}
#[derive(Clone)]
pub struct McpSamplingRequest {
pub server_id: String,
pub request: CreateMessageRequest,
pub messages: Vec<ChatMessage>,
}
#[derive(Debug, Clone)]
pub struct McpPromptRequest {
pub server_id: String,
pub prompt_name: String,
pub arguments: std::collections::HashMap<String, String>,
}
pub struct SessionBootstrap {
pub session: SessionContext,
pub theme: Theme,
pub startup_requires_provider: bool,
pub startup_errors: Vec<String>,
}
pub struct UninitializedSessionBootstrap {
pub session: SessionContext,
pub theme: Theme,
pub config: Config,
pub startup_requires_provider: bool,
}
pub(crate) struct PrepareWithAuthInput<'a> {
pub model: String,
pub log_file: Option<String>,
pub provider: Option<String>,
pub env_only: bool,
pub config: &'a Config,
pub pre_resolved_session: Option<ProviderSession>,
pub character: Option<String>,
pub character_service: &'a mut CharacterService,
}
impl SessionContext {
pub fn set_character(&mut self, card: CharacterCard) {
let is_same_character = self
.active_character
.as_ref()
.map(|current| current.data.name == card.data.name)
.unwrap_or(false);
self.active_character = Some(card);
if !is_same_character {
self.character_greeting_shown = false;
}
}
pub fn clear_character(&mut self) {
self.active_character = None;
self.character_greeting_shown = false;
}
pub fn get_character(&self) -> Option<&CharacterCard> {
self.active_character.as_ref()
}
pub fn should_show_greeting(&self) -> bool {
if let Some(character) = &self.active_character {
!self.character_greeting_shown && !character.data.first_mes.trim().is_empty()
} else {
false
}
}
pub fn mark_greeting_shown(&mut self) {
self.character_greeting_shown = true;
}
#[cfg(test)]
pub fn for_test(provider_name: &str, model: &str) -> Self {
Self {
client: Client::new(),
model: model.to_string(),
api_key: "test-api-key".to_string(),
base_url: "https://example.invalid".to_string(),
provider_name: provider_name.to_string(),
provider_display_name: provider_name.to_string(),
logging: LoggingState::new(None).expect("test logging"),
stream_cancel_token: None,
current_stream_id: 0,
last_retry_time: Instant::now(),
retrying_message_index: None,
is_refining: false,
original_refining_content: None,
last_refine_prompt: None,
refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
startup_env_only: false,
mcp_disabled: false,
active_character: None,
character_greeting_shown: false,
has_received_assistant_message: false,
tool_pipeline: ToolPipelineState::default(),
mcp_init: McpInitState::default(),
active_assistant_message_index: None,
mcp_tools_enabled: false,
mcp_tools_unsupported: false,
}
}
}
impl ToolPipelineState {
pub fn reset(&mut self) {
self.pending_tool_calls.clear();
self.pending_tool_queue.clear();
self.active_tool_request = None;
self.pending_sampling_queue.clear();
self.active_sampling_request = None;
self.tool_call_records.clear();
self.tool_results.clear();
self.continuation_messages = None;
}
pub fn advance_tool_queue(&mut self) -> Option<&ToolCallRequest> {
let request = self.pending_tool_queue.pop_front()?;
self.active_tool_request = Some(request);
self.active_tool_request.as_ref()
}
pub fn advance_sampling_queue(&mut self) -> Option<&McpSamplingRequest> {
let request = self.pending_sampling_queue.pop_front()?;
self.active_sampling_request = Some(request);
self.active_sampling_request.as_ref()
}
pub fn record_result(
&mut self,
record: ToolResultRecord,
payload: Option<ToolPayloadHistoryEntry>,
) {
self.tool_result_history.push(record);
if let Some(payload) = payload {
self.tool_payload_history.push(payload);
}
}
pub fn prune_for_assistant_index(&mut self, index: usize) {
self.prune_records(|candidate| candidate == index);
}
pub fn prune_from_index(&mut self, start: usize) {
self.prune_records(|candidate| candidate >= start);
}
pub fn clear_server_records(&mut self, server_id: &str) {
self.tool_result_history.retain(|record| {
record
.server_id
.as_deref()
.map(|id| !id.eq_ignore_ascii_case(server_id))
.unwrap_or(true)
});
self.tool_payload_history.retain(|entry| {
entry
.server_id
.as_deref()
.map(|id| !id.eq_ignore_ascii_case(server_id))
.unwrap_or(true)
});
}
pub fn set_continuation(&mut self, messages: Vec<ChatMessage>, base: Vec<ChatMessage>) {
self.continuation_messages = Some(StreamContinuation {
api_messages: messages,
api_messages_base: base,
});
}
pub fn take_continuation(&mut self) -> Option<StreamContinuation> {
self.continuation_messages.take()
}
fn prune_records<F>(&mut self, predicate: F)
where
F: Fn(usize) -> bool,
{
self.tool_result_history.retain(|record| {
record
.assistant_message_index
.map(|idx| !predicate(idx))
.unwrap_or(true)
});
self.tool_payload_history.retain(|entry| {
entry
.assistant_message_index
.map(|idx| !predicate(idx))
.unwrap_or(true)
});
}
}
impl McpInitState {
pub fn begin(&mut self) {
self.in_progress = true;
self.complete = false;
}
pub fn complete(&mut self) -> Option<String> {
self.in_progress = false;
self.complete = true;
self.deferred_message.take()
}
pub fn should_defer(&self) -> bool {
self.in_progress && !self.complete
}
pub fn reset(&mut self) {
self.in_progress = false;
self.complete = false;
self.deferred_message = None;
}
}
#[derive(Debug)]
pub(crate) struct CharacterLoadOutcome {
pub character: Option<CharacterCard>,
pub errors: Vec<String>,
}
pub fn exit_with_provider_resolution_error(err: &ProviderResolutionError) -> ! {
eprintln!("{}", err);
let fixes = err.quick_fixes();
if !fixes.is_empty() {
eprintln!();
eprintln!("💡 Quick fixes:");
for fix in fixes {
eprintln!(" • {fix}");
}
}
std::process::exit(err.exit_code());
}
pub fn exit_if_env_only_missing_env(env_only: bool) {
if env_only && std::env::var("OPENAI_API_KEY").is_err() {
eprintln!("❌ --env used but OPENAI_API_KEY is not set");
std::process::exit(2);
}
}
pub(crate) fn load_character_for_session(
cli_character: Option<&str>,
provider: &str,
model: &str,
config: &Config,
character_service: &mut CharacterService,
) -> Result<CharacterLoadOutcome, Box<dyn std::error::Error>> {
if let Some(character_name) = cli_character {
let card = character_service
.resolve(character_name)
.map_err(|err| Box::new(err) as Box<dyn std::error::Error>)?;
return Ok(CharacterLoadOutcome {
character: Some(card),
errors: Vec::new(),
});
}
let mut errors = Vec::new();
match character_service.load_default_for_session(provider, model, config) {
Ok(Some((_name, card))) => {
return Ok(CharacterLoadOutcome {
character: Some(card),
errors,
})
}
Ok(None) => {}
Err(err) => {
if let Some(default_character) = config.get_default_character(provider, model) {
errors.push(format!(
"Failed to load default character '{}' for {}:{}: {}",
default_character, provider, model, err
));
} else {
errors.push(format!(
"Failed to load default character for {}:{}: {}",
provider, model, err
));
}
}
}
Ok(CharacterLoadOutcome {
character: None,
errors,
})
}
pub(crate) fn initialize_logging(
log_file: Option<String>,
) -> Result<LoggingState, Box<dyn std::error::Error>> {
let mut logging = LoggingState::new(log_file.clone())?;
if let Some(log_path) = log_file {
if let Err(e) = logging.set_log_file(log_path.clone()) {
eprintln!(
"Warning: Failed to enable startup logging ({}): {}",
log_path, e
);
}
}
Ok(logging)
}
fn theme_from_appearance(appearance: Appearance) -> Theme {
match appearance {
Appearance::Light => Theme::light(),
Appearance::Dark => Theme::dark_default(),
}
}
pub(crate) fn resolve_theme(config: &Config) -> Theme {
let resolved_theme = match &config.theme {
Some(name) => {
if let Some(ct) = config.get_custom_theme(name) {
Theme::from_spec(&theme_spec_from_custom(ct))
} else if let Some(spec) = find_builtin_theme(name) {
Theme::from_spec(&spec)
} else {
Theme::from_name(name)
}
}
None => detect_preferred_appearance()
.map(theme_from_appearance)
.unwrap_or_else(Theme::dark_default),
};
quantize_theme_for_current_terminal(resolved_theme)
}
pub(crate) async fn prepare_with_auth(
input: PrepareWithAuthInput<'_>,
) -> Result<SessionBootstrap, Box<dyn std::error::Error>> {
let PrepareWithAuthInput {
model,
log_file,
provider,
env_only,
config,
pre_resolved_session,
character,
character_service,
} = input;
let session = if let Some(session) = pre_resolved_session {
session
} else if env_only {
resolve_env_session().map_err(|err| Box::new(err) as Box<dyn std::error::Error>)?
} else {
let auth_manager = AuthManager::new()?;
match resolve_session(&auth_manager, config, provider.as_deref()) {
Ok(session) => session,
Err(ResolveSessionError::Provider(err)) => return Err(Box::new(err)),
Err(ResolveSessionError::Source(err)) => return Err(err),
}
};
let (api_key, base_url, provider_name, provider_display_name) = session.into_tuple();
let final_model = if model != "default" {
model
} else if let Some(default_model) = config.get_default_model(&provider_name) {
default_model.clone()
} else {
String::new()
};
let _api_endpoint = construct_api_url(&base_url, "chat/completions");
let logging = initialize_logging(log_file)?;
let resolved_theme = resolve_theme(config);
let CharacterLoadOutcome {
character: active_character,
errors: startup_errors,
} = load_character_for_session(
character.as_deref(),
&provider_name,
&final_model,
config,
character_service,
)?;
let session = SessionContext {
client: Client::new(),
model: final_model,
api_key,
base_url,
provider_name: provider_name.to_string(),
provider_display_name,
logging,
stream_cancel_token: None,
current_stream_id: 0,
last_retry_time: Instant::now(),
retrying_message_index: None,
is_refining: false,
original_refining_content: None,
last_refine_prompt: None,
refine_instructions: config.refine_instructions().into_owned(),
refine_prefix: config.refine_prefix().into_owned(),
startup_env_only: false,
mcp_disabled: false,
active_character,
character_greeting_shown: false,
has_received_assistant_message: false,
tool_pipeline: ToolPipelineState::default(),
mcp_init: McpInitState::default(),
active_assistant_message_index: None,
mcp_tools_enabled: false,
mcp_tools_unsupported: false,
};
Ok(SessionBootstrap {
session,
theme: resolved_theme,
startup_requires_provider: false,
startup_errors,
})
}
pub(crate) async fn prepare_uninitialized(
log_file: Option<String>,
_character_service: &mut CharacterService,
) -> Result<UninitializedSessionBootstrap, Box<dyn std::error::Error>> {
let config = Config::load()?;
let logging = initialize_logging(log_file)?;
let resolved_theme = resolve_theme(&config);
let session = SessionContext {
client: Client::new(),
model: String::new(),
api_key: String::new(),
base_url: String::new(),
provider_name: String::new(),
provider_display_name: "(no provider selected)".to_string(),
logging,
stream_cancel_token: None,
current_stream_id: 0,
last_retry_time: Instant::now(),
retrying_message_index: None,
is_refining: false,
original_refining_content: None,
last_refine_prompt: None,
refine_instructions: config.refine_instructions().into_owned(),
refine_prefix: config.refine_prefix().into_owned(),
startup_env_only: false,
mcp_disabled: false,
active_character: None,
character_greeting_shown: false,
has_received_assistant_message: false,
tool_pipeline: ToolPipelineState::default(),
mcp_init: McpInitState::default(),
active_assistant_message_index: None,
mcp_tools_enabled: false,
mcp_tools_unsupported: false,
};
Ok(UninitializedSessionBootstrap {
session,
theme: resolved_theme,
config,
startup_requires_provider: true,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::data::Config;
use crate::core::providers::ProviderSession;
use crate::utils::test_utils::TestEnvVarGuard;
use tempfile::tempdir;
#[test]
fn theme_from_appearance_matches_light_theme() {
let theme = theme_from_appearance(Appearance::Light);
assert_eq!(theme.background_color, Theme::light().background_color);
}
#[test]
fn theme_from_appearance_matches_dark_theme() {
let theme = theme_from_appearance(Appearance::Dark);
assert_eq!(
theme.background_color,
Theme::dark_default().background_color
);
}
#[test]
fn resolve_theme_prefers_configured_theme() {
let config = Config {
theme: Some("light".to_string()),
..Default::default()
};
let resolved_theme = resolve_theme(&config);
let expected_theme = quantize_theme_for_current_terminal(Theme::light());
assert_eq!(
resolved_theme.background_color,
expected_theme.background_color
);
}
#[test]
fn prepare_with_auth_uses_pre_resolved_session() {
let provider_session = ProviderSession {
api_key: "test-key".to_string(),
base_url: "https://example.invalid".to_string(),
provider_id: "test-provider".to_string(),
provider_display_name: "Test Provider".to_string(),
};
let config = Config::default();
let runtime = tokio::runtime::Runtime::new().expect("runtime");
let mut service = crate::character::CharacterService::new();
let bootstrap = runtime
.block_on(super::prepare_with_auth(super::PrepareWithAuthInput {
model: "default".to_string(),
log_file: None,
provider: None,
env_only: false,
config: &config,
pre_resolved_session: Some(provider_session.clone()),
character: None,
character_service: &mut service,
}))
.expect("prepare_with_auth");
assert_eq!(bootstrap.session.api_key, provider_session.api_key);
assert_eq!(bootstrap.session.base_url, provider_session.base_url);
assert_eq!(
bootstrap.session.provider_name,
provider_session.provider_id
);
assert_eq!(
bootstrap.session.provider_display_name,
provider_session.provider_display_name
);
assert!(!bootstrap.startup_requires_provider);
assert!(!bootstrap.session.startup_env_only);
assert!(bootstrap.session.active_character.is_none());
assert!(!bootstrap.session.character_greeting_shown);
}
#[test]
fn prepare_with_auth_uses_env_session_when_env_only() {
let mut env_guard = TestEnvVarGuard::new();
env_guard.set_var("OPENAI_API_KEY", "sk-env");
env_guard.set_var("OPENAI_BASE_URL", "https://example.com/v1");
let config = Config::default();
let runtime = tokio::runtime::Runtime::new().expect("runtime");
let mut service = crate::character::CharacterService::new();
let bootstrap = runtime
.block_on(super::prepare_with_auth(super::PrepareWithAuthInput {
model: "default".to_string(),
log_file: None,
provider: None,
env_only: true,
config: &config,
pre_resolved_session: None,
character: None,
character_service: &mut service,
}))
.expect("prepare_with_auth");
assert_eq!(bootstrap.session.api_key, "sk-env");
assert_eq!(bootstrap.session.base_url, "https://example.com/v1");
assert_eq!(bootstrap.session.provider_name, "openai-compatible");
assert_eq!(bootstrap.session.provider_display_name, "OpenAI-compatible");
}
#[test]
fn initialize_logging_with_file_writes_initial_entry() {
let temp_dir = tempdir().expect("tempdir");
let log_path = temp_dir.path().join("startup.log");
let log_file = log_path.to_string_lossy().to_string();
let logging = initialize_logging(Some(log_file.clone())).expect("logging initialized");
logging
.log_message("Hello from startup")
.expect("log message");
let contents = std::fs::read_to_string(&log_path).expect("read log file");
assert!(contents.contains("Hello from startup"));
}
#[test]
fn tool_pipeline_reset_clears_active_and_queues() {
let mut pipeline = ToolPipelineState::default();
pipeline.pending_tool_queue.push_back(ToolCallRequest {
server_id: "s".into(),
tool_name: "t".into(),
arguments: None,
raw_arguments: "{}".into(),
tool_call_id: Some("call".into()),
});
pipeline.active_tool_request = pipeline.pending_tool_queue.front().cloned();
pipeline.set_continuation(Vec::new(), Vec::new());
pipeline.reset();
assert!(pipeline.pending_tool_queue.is_empty());
assert!(pipeline.pending_sampling_queue.is_empty());
assert!(pipeline.active_tool_request.is_none());
assert!(pipeline.active_sampling_request.is_none());
assert!(pipeline.continuation_messages.is_none());
}
#[test]
fn tool_pipeline_advance_tool_queue_handles_empty_and_item() {
let mut pipeline = ToolPipelineState::default();
assert!(pipeline.advance_tool_queue().is_none());
pipeline.pending_tool_queue.push_back(ToolCallRequest {
server_id: "s".into(),
tool_name: "t".into(),
arguments: None,
raw_arguments: "{}".into(),
tool_call_id: None,
});
let request = pipeline.advance_tool_queue().expect("advanced");
assert_eq!(request.tool_name, "t");
assert!(pipeline.pending_tool_queue.is_empty());
}
#[test]
fn tool_pipeline_prune_for_assistant_index_removes_matching_records() {
let mut pipeline = ToolPipelineState::default();
pipeline.tool_result_history.push(ToolResultRecord {
tool_name: "keep".into(),
server_name: None,
server_id: Some("server".into()),
status: ToolResultStatus::Success,
failure_kind: None,
content: "ok".into(),
summary: "ok".into(),
tool_call_id: Some("keep".into()),
raw_arguments: None,
assistant_message_index: Some(1),
});
pipeline.tool_result_history.push(ToolResultRecord {
tool_name: "drop".into(),
server_name: None,
server_id: Some("server".into()),
status: ToolResultStatus::Error,
failure_kind: Some(ToolFailureKind::ToolError),
content: "fail".into(),
summary: "fail".into(),
tool_call_id: Some("drop".into()),
raw_arguments: None,
assistant_message_index: Some(3),
});
pipeline.prune_for_assistant_index(3);
assert_eq!(pipeline.tool_result_history.len(), 1);
assert_eq!(pipeline.tool_result_history[0].tool_name, "keep");
}
#[test]
fn tool_pipeline_continuation_round_trip_and_drain() {
let mut pipeline = ToolPipelineState::default();
pipeline.set_continuation(Vec::new(), Vec::new());
assert!(pipeline.take_continuation().is_some());
assert!(pipeline.take_continuation().is_none());
}
#[test]
fn mcp_init_state_complete_returns_message_and_clears_progress() {
let mut state = McpInitState::default();
state.begin();
state.deferred_message = Some("hello".into());
let deferred = state.complete();
assert_eq!(deferred.as_deref(), Some("hello"));
assert!(state.complete);
assert!(!state.in_progress);
assert!(state.deferred_message.is_none());
}
#[test]
fn mcp_init_state_should_defer_only_while_in_progress() {
let mut state = McpInitState::default();
assert!(!state.should_defer());
state.begin();
assert!(state.should_defer());
state.complete();
assert!(!state.should_defer());
}
#[test]
fn mcp_init_state_reset_restores_default() {
let mut state = McpInitState {
in_progress: true,
complete: true,
deferred_message: Some("queued".into()),
};
state.reset();
assert!(!state.in_progress);
assert!(!state.complete);
assert!(state.deferred_message.is_none());
}
#[test]
fn session_context_set_character() {
use crate::character::card::{CharacterCard, CharacterData};
let mut session = SessionContext {
client: Client::new(),
model: String::new(),
api_key: String::new(),
base_url: String::new(),
provider_name: String::new(),
provider_display_name: String::new(),
logging: LoggingState::new(None).unwrap(),
stream_cancel_token: None,
current_stream_id: 0,
last_retry_time: Instant::now(),
retrying_message_index: None,
is_refining: false,
original_refining_content: None,
last_refine_prompt: None,
refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
startup_env_only: false,
mcp_disabled: false,
active_character: None,
character_greeting_shown: false,
has_received_assistant_message: false,
tool_pipeline: ToolPipelineState::default(),
mcp_init: McpInitState::default(),
active_assistant_message_index: None,
mcp_tools_enabled: false,
mcp_tools_unsupported: false,
};
let card = CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "Test".to_string(),
description: "Test character".to_string(),
personality: "Friendly".to_string(),
scenario: "Testing".to_string(),
first_mes: "Hello!".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
};
session.set_character(card.clone());
assert!(session.active_character.is_some());
assert_eq!(session.get_character().unwrap().data.name, "Test");
assert!(!session.character_greeting_shown);
}
#[test]
fn session_context_clear_character() {
use crate::character::card::{CharacterCard, CharacterData};
let mut session = SessionContext {
client: Client::new(),
model: String::new(),
api_key: String::new(),
base_url: String::new(),
provider_name: String::new(),
provider_display_name: String::new(),
logging: LoggingState::new(None).unwrap(),
stream_cancel_token: None,
current_stream_id: 0,
last_retry_time: Instant::now(),
retrying_message_index: None,
is_refining: false,
original_refining_content: None,
last_refine_prompt: None,
refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
startup_env_only: false,
mcp_disabled: false,
active_character: Some(CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "Test".to_string(),
description: "Test character".to_string(),
personality: "Friendly".to_string(),
scenario: "Testing".to_string(),
first_mes: "Hello!".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
}),
character_greeting_shown: true,
has_received_assistant_message: false,
tool_pipeline: ToolPipelineState::default(),
mcp_init: McpInitState::default(),
active_assistant_message_index: None,
mcp_tools_enabled: false,
mcp_tools_unsupported: false,
};
session.clear_character();
assert!(session.active_character.is_none());
assert!(!session.character_greeting_shown);
}
#[test]
fn session_context_should_show_greeting() {
use crate::character::card::{CharacterCard, CharacterData};
let mut session = SessionContext {
client: Client::new(),
model: String::new(),
api_key: String::new(),
base_url: String::new(),
provider_name: String::new(),
provider_display_name: String::new(),
logging: LoggingState::new(None).unwrap(),
stream_cancel_token: None,
current_stream_id: 0,
last_retry_time: Instant::now(),
retrying_message_index: None,
is_refining: false,
original_refining_content: None,
last_refine_prompt: None,
refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
startup_env_only: false,
mcp_disabled: false,
active_character: Some(CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "Test".to_string(),
description: "Test character".to_string(),
personality: "Friendly".to_string(),
scenario: "Testing".to_string(),
first_mes: "Hello!".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
}),
character_greeting_shown: false,
has_received_assistant_message: false,
tool_pipeline: ToolPipelineState::default(),
mcp_init: McpInitState::default(),
active_assistant_message_index: None,
mcp_tools_enabled: false,
mcp_tools_unsupported: false,
};
assert!(session.should_show_greeting());
session.mark_greeting_shown();
assert!(!session.should_show_greeting());
}
#[test]
fn session_context_should_not_show_empty_greeting() {
use crate::character::card::{CharacterCard, CharacterData};
let session = SessionContext {
client: Client::new(),
model: String::new(),
api_key: String::new(),
base_url: String::new(),
provider_name: String::new(),
provider_display_name: String::new(),
logging: LoggingState::new(None).unwrap(),
stream_cancel_token: None,
current_stream_id: 0,
last_retry_time: Instant::now(),
retrying_message_index: None,
is_refining: false,
original_refining_content: None,
last_refine_prompt: None,
refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
startup_env_only: false,
mcp_disabled: false,
active_character: Some(CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "Test".to_string(),
description: "Test character".to_string(),
personality: "Friendly".to_string(),
scenario: "Testing".to_string(),
first_mes: " ".to_string(), mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
}),
character_greeting_shown: false,
has_received_assistant_message: false,
tool_pipeline: ToolPipelineState::default(),
mcp_init: McpInitState::default(),
active_assistant_message_index: None,
mcp_tools_enabled: false,
mcp_tools_unsupported: false,
};
assert!(!session.should_show_greeting());
}
#[test]
fn load_character_for_session_no_character() {
let config = Config::default();
let mut service = crate::character::CharacterService::new();
let outcome =
super::load_character_for_session(None, "openai", "gpt-4", &config, &mut service)
.expect("load_character_for_session");
assert!(outcome.character.is_none());
assert!(outcome.errors.is_empty());
}
#[test]
fn load_character_for_session_cli_takes_precedence() {
use crate::character::card::{CharacterCard, CharacterData};
use std::collections::HashMap;
use std::fs;
let temp_dir = tempdir().expect("tempdir");
let cards_dir = temp_dir.path().join("cards");
fs::create_dir_all(&cards_dir).expect("create cards dir");
let card = CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "TestChar".to_string(),
description: "Test".to_string(),
personality: "Friendly".to_string(),
scenario: "Testing".to_string(),
first_mes: "Hello!".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
};
let card_path = cards_dir.join("testchar.json");
let card_json = serde_json::to_string(&card).expect("serialize card");
fs::write(&card_path, card_json).expect("write card");
let mut default_chars = HashMap::new();
let mut openai_models = HashMap::new();
openai_models.insert("gpt-4".to_string(), "other-char".to_string());
default_chars.insert("openai".to_string(), openai_models);
let config = Config {
default_characters: default_chars,
..Default::default()
};
let mut service = crate::character::CharacterService::new();
let result = super::load_character_for_session(
Some(card_path.to_str().unwrap()),
"openai",
"gpt-4",
&config,
&mut service,
);
let outcome = result.expect("cli load");
assert!(outcome.errors.is_empty());
assert_eq!(
outcome.character.expect("character loaded").data.name,
"TestChar"
);
}
#[test]
fn load_character_for_session_filepath_fallback() {
use crate::character::card::{CharacterCard, CharacterData};
use std::fs;
let temp_dir = tempdir().expect("tempdir");
let card = CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "FilePathChar".to_string(),
description: "Loaded from file path".to_string(),
personality: "Friendly".to_string(),
scenario: "Testing".to_string(),
first_mes: "Hello from file!".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
};
let card_path = temp_dir.path().join("external_card.json");
let card_json = serde_json::to_string(&card).expect("serialize card");
fs::write(&card_path, card_json).expect("write card");
let config = Config::default();
let mut service = crate::character::CharacterService::new();
let result = super::load_character_for_session(
Some(card_path.to_str().unwrap()),
"openai",
"gpt-4",
&config,
&mut service,
);
assert!(result.is_ok());
let outcome = result.unwrap();
assert!(outcome.character.is_some());
assert_eq!(outcome.character.unwrap().data.name, "FilePathChar");
assert!(outcome.errors.is_empty());
}
#[test]
fn load_character_for_session_cards_dir_priority() {
use crate::character::card::{CharacterCard, CharacterData};
use std::fs;
let temp_dir = tempdir().expect("tempdir");
let wrong_card = CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "WrongChar".to_string(),
description: "Should not be loaded".to_string(),
personality: "Wrong".to_string(),
scenario: "Wrong".to_string(),
first_mes: "Wrong!".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
};
let wrong_path = temp_dir.path().join("data.json");
let wrong_json = serde_json::to_string(&wrong_card).expect("serialize card");
fs::write(&wrong_path, wrong_json).expect("write card");
let config = Config::default();
let mut service = crate::character::CharacterService::new();
let result = super::load_character_for_session(
Some("data"),
"openai",
"gpt-4",
&config,
&mut service,
);
assert!(result.is_err());
}
#[test]
fn session_context_get_character_returns_none_initially() {
let session = SessionContext {
client: Client::new(),
model: String::new(),
api_key: String::new(),
base_url: String::new(),
provider_name: String::new(),
provider_display_name: String::new(),
logging: LoggingState::new(None).unwrap(),
stream_cancel_token: None,
current_stream_id: 0,
last_retry_time: Instant::now(),
retrying_message_index: None,
is_refining: false,
original_refining_content: None,
last_refine_prompt: None,
refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
startup_env_only: false,
mcp_disabled: false,
active_character: None,
character_greeting_shown: false,
has_received_assistant_message: false,
tool_pipeline: ToolPipelineState::default(),
mcp_init: McpInitState::default(),
active_assistant_message_index: None,
mcp_tools_enabled: false,
mcp_tools_unsupported: false,
};
assert!(session.get_character().is_none());
assert!(!session.should_show_greeting());
}
#[test]
fn session_context_greeting_lifecycle() {
use crate::character::card::{CharacterCard, CharacterData};
let mut session = SessionContext {
client: Client::new(),
model: String::new(),
api_key: String::new(),
base_url: String::new(),
provider_name: String::new(),
provider_display_name: String::new(),
logging: LoggingState::new(None).unwrap(),
stream_cancel_token: None,
current_stream_id: 0,
last_retry_time: Instant::now(),
retrying_message_index: None,
is_refining: false,
original_refining_content: None,
last_refine_prompt: None,
refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
startup_env_only: false,
mcp_disabled: false,
active_character: None,
character_greeting_shown: false,
has_received_assistant_message: false,
tool_pipeline: ToolPipelineState::default(),
mcp_init: McpInitState::default(),
active_assistant_message_index: None,
mcp_tools_enabled: false,
mcp_tools_unsupported: false,
};
assert!(!session.should_show_greeting());
let card = CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "Test".to_string(),
description: "Test character".to_string(),
personality: "Friendly".to_string(),
scenario: "Testing".to_string(),
first_mes: "Hello there!".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
};
session.set_character(card);
assert!(session.should_show_greeting());
session.mark_greeting_shown();
assert!(!session.should_show_greeting());
session.clear_character();
assert!(!session.should_show_greeting());
}
#[test]
fn session_context_reselecting_same_character_preserves_greeting_flag() {
use crate::character::card::{CharacterCard, CharacterData};
let mut session = SessionContext {
client: Client::new(),
model: String::new(),
api_key: String::new(),
base_url: String::new(),
provider_name: String::new(),
provider_display_name: String::new(),
logging: LoggingState::new(None).unwrap(),
stream_cancel_token: None,
current_stream_id: 0,
last_retry_time: Instant::now(),
retrying_message_index: None,
is_refining: false,
original_refining_content: None,
last_refine_prompt: None,
refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
startup_env_only: false,
mcp_disabled: false,
active_character: None,
character_greeting_shown: false,
has_received_assistant_message: false,
tool_pipeline: ToolPipelineState::default(),
mcp_init: McpInitState::default(),
active_assistant_message_index: None,
mcp_tools_enabled: false,
mcp_tools_unsupported: false,
};
let card = CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "Test".to_string(),
description: "Test character".to_string(),
personality: "Friendly".to_string(),
scenario: "Testing".to_string(),
first_mes: "Hello there!".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
};
session.set_character(card.clone());
assert!(session.should_show_greeting());
session.mark_greeting_shown();
assert!(!session.should_show_greeting());
session.set_character(card);
assert!(!session.should_show_greeting());
assert!(session.character_greeting_shown);
}
#[test]
fn session_context_selecting_different_character_resets_greeting_flag() {
use crate::character::card::{CharacterCard, CharacterData};
let mut session = SessionContext {
client: Client::new(),
model: String::new(),
api_key: String::new(),
base_url: String::new(),
provider_name: String::new(),
provider_display_name: String::new(),
logging: LoggingState::new(None).unwrap(),
stream_cancel_token: None,
current_stream_id: 0,
last_retry_time: Instant::now(),
retrying_message_index: None,
is_refining: false,
original_refining_content: None,
last_refine_prompt: None,
refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
startup_env_only: false,
mcp_disabled: false,
active_character: None,
character_greeting_shown: false,
has_received_assistant_message: false,
tool_pipeline: ToolPipelineState::default(),
mcp_init: McpInitState::default(),
active_assistant_message_index: None,
mcp_tools_enabled: false,
mcp_tools_unsupported: false,
};
let card1 = CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "Test1".to_string(),
description: "Test character 1".to_string(),
personality: "Friendly".to_string(),
scenario: "Testing".to_string(),
first_mes: "Hello from Test1!".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
};
let card2 = CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "Test2".to_string(),
description: "Test character 2".to_string(),
personality: "Helpful".to_string(),
scenario: "Testing".to_string(),
first_mes: "Hello from Test2!".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
};
session.set_character(card1);
session.mark_greeting_shown();
assert!(!session.should_show_greeting());
session.set_character(card2);
assert!(session.should_show_greeting());
assert!(!session.character_greeting_shown);
}
}