Skip to main content

chabeau/core/app/
session.rs

1//! Session context and metadata tracking.
2//!
3//! This module defines [`SessionContext`], which captures runtime state for
4//! an active chat session including the selected provider, model, HTTP client,
5//! theme, logging configuration, and streaming cancellation tokens.
6//!
7//! Session metadata allows downstream components to act without re-querying
8//! configuration or authentication state during the conversation lifecycle.
9
10use std::collections::{BTreeMap, VecDeque};
11use std::time::Instant;
12
13use reqwest::Client;
14use rust_mcp_schema::CreateMessageRequest;
15use tokio_util::sync::CancellationToken;
16
17use crate::api::{ChatMessage, ChatToolCall};
18use crate::auth::AuthManager;
19use crate::character::card::CharacterCard;
20use crate::character::service::CharacterService;
21use crate::core::config::data::Config;
22#[cfg(test)]
23use crate::core::config::data::{DEFAULT_REFINE_INSTRUCTIONS, DEFAULT_REFINE_PREFIX};
24use crate::core::providers::{
25    resolve_env_session, resolve_session, ProviderResolutionError, ProviderSession,
26    ResolveSessionError,
27};
28use crate::ui::appearance::{detect_preferred_appearance, Appearance};
29use crate::ui::builtin_themes::{find_builtin_theme, theme_spec_from_custom};
30use crate::ui::theme::Theme;
31use crate::utils::color::quantize_theme_for_current_terminal;
32use crate::utils::logging::LoggingState;
33use crate::utils::url::construct_api_url;
34
35pub struct SessionContext {
36    pub client: Client,
37    pub model: String,
38    pub api_key: String,
39    pub base_url: String,
40    pub provider_name: String,
41    pub provider_display_name: String,
42    pub logging: LoggingState,
43    pub stream_cancel_token: Option<CancellationToken>,
44    pub current_stream_id: u64,
45    pub last_retry_time: Instant,
46    pub retrying_message_index: Option<usize>,
47    pub is_refining: bool,
48    pub original_refining_content: Option<String>,
49    pub last_refine_prompt: Option<String>,
50    pub refine_instructions: String,
51    pub refine_prefix: String,
52    pub startup_env_only: bool,
53    pub mcp_disabled: bool,
54    pub active_character: Option<CharacterCard>,
55    pub character_greeting_shown: bool,
56    pub has_received_assistant_message: bool,
57    pub tool_pipeline: ToolPipelineState,
58    pub mcp_init: McpInitState,
59    pub active_assistant_message_index: Option<usize>,
60    pub mcp_tools_enabled: bool,
61    pub mcp_tools_unsupported: bool,
62}
63
64#[derive(Default, Clone)]
65pub struct ToolPipelineState {
66    pub pending_tool_calls: BTreeMap<u32, PendingToolCall>,
67    pub pending_tool_queue: VecDeque<ToolCallRequest>,
68    pub active_tool_request: Option<ToolCallRequest>,
69    pub pending_sampling_queue: VecDeque<McpSamplingRequest>,
70    pub active_sampling_request: Option<McpSamplingRequest>,
71    pub tool_call_records: Vec<ChatToolCall>,
72    pub tool_results: Vec<ChatMessage>,
73    pub tool_result_history: Vec<ToolResultRecord>,
74    pub tool_payload_history: Vec<ToolPayloadHistoryEntry>,
75    pub continuation_messages: Option<StreamContinuation>,
76}
77
78#[derive(Clone)]
79pub struct StreamContinuation {
80    pub api_messages: Vec<ChatMessage>,
81    pub api_messages_base: Vec<ChatMessage>,
82}
83
84#[derive(Default)]
85pub struct McpInitState {
86    pub in_progress: bool,
87    pub complete: bool,
88    pub deferred_message: Option<String>,
89}
90
91#[derive(Debug, Clone)]
92pub struct PendingToolCall {
93    pub id: Option<String>,
94    pub name: Option<String>,
95    pub arguments: String,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum ToolResultStatus {
100    Success,
101    Error,
102    Denied,
103    Blocked,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum ToolFailureKind {
108    ToolError,
109    ToolCallFailure,
110}
111
112impl ToolFailureKind {
113    pub fn label(self) -> &'static str {
114        match self {
115            ToolFailureKind::ToolError => "tool error",
116            ToolFailureKind::ToolCallFailure => "tool call failure",
117        }
118    }
119
120    pub fn display(self) -> &'static str {
121        match self {
122            ToolFailureKind::ToolError => "Tool error",
123            ToolFailureKind::ToolCallFailure => "Tool call failure",
124        }
125    }
126}
127
128impl ToolResultStatus {
129    pub fn label(self) -> &'static str {
130        match self {
131            ToolResultStatus::Success => "success",
132            ToolResultStatus::Error => "failed",
133            ToolResultStatus::Denied => "denied",
134            ToolResultStatus::Blocked => "blocked",
135        }
136    }
137
138    pub fn display(self) -> &'static str {
139        match self {
140            ToolResultStatus::Success => "Success",
141            ToolResultStatus::Error => "Failed",
142            ToolResultStatus::Denied => "Denied",
143            ToolResultStatus::Blocked => "Blocked",
144        }
145    }
146}
147
148#[derive(Debug, Clone)]
149pub struct ToolResultRecord {
150    pub tool_name: String,
151    pub server_name: Option<String>,
152    pub server_id: Option<String>,
153    pub status: ToolResultStatus,
154    pub failure_kind: Option<ToolFailureKind>,
155    pub content: String,
156    pub summary: String,
157    pub tool_call_id: Option<String>,
158    pub raw_arguments: Option<String>,
159    pub assistant_message_index: Option<usize>,
160}
161
162#[derive(Clone)]
163pub struct ToolPayloadHistoryEntry {
164    pub server_id: Option<String>,
165    pub tool_call_id: Option<String>,
166    pub assistant_message: ChatMessage,
167    pub tool_message: ChatMessage,
168    pub assistant_message_index: Option<usize>,
169}
170
171#[derive(Debug, Clone)]
172pub struct ToolCallRequest {
173    pub server_id: String,
174    pub tool_name: String,
175    pub arguments: Option<serde_json::Map<String, serde_json::Value>>,
176    pub raw_arguments: String,
177    pub tool_call_id: Option<String>,
178}
179
180#[derive(Clone)]
181pub struct McpSamplingRequest {
182    pub server_id: String,
183    pub request: CreateMessageRequest,
184    pub messages: Vec<ChatMessage>,
185}
186
187#[derive(Debug, Clone)]
188pub struct McpPromptRequest {
189    pub server_id: String,
190    pub prompt_name: String,
191    pub arguments: std::collections::HashMap<String, String>,
192}
193
194pub struct SessionBootstrap {
195    pub session: SessionContext,
196    pub theme: Theme,
197    pub startup_requires_provider: bool,
198    pub startup_errors: Vec<String>,
199}
200
201pub struct UninitializedSessionBootstrap {
202    pub session: SessionContext,
203    pub theme: Theme,
204    pub config: Config,
205    pub startup_requires_provider: bool,
206}
207
208pub(crate) struct PrepareWithAuthInput<'a> {
209    pub model: String,
210    pub log_file: Option<String>,
211    pub provider: Option<String>,
212    pub env_only: bool,
213    pub config: &'a Config,
214    pub pre_resolved_session: Option<ProviderSession>,
215    pub character: Option<String>,
216    pub character_service: &'a mut CharacterService,
217}
218
219impl SessionContext {
220    /// Set the active character card
221    pub fn set_character(&mut self, card: CharacterCard) {
222        // Check if this is the same character that's already active
223        let is_same_character = self
224            .active_character
225            .as_ref()
226            .map(|current| current.data.name == card.data.name)
227            .unwrap_or(false);
228
229        self.active_character = Some(card);
230
231        // Only reset greeting flag if this is a different character
232        if !is_same_character {
233            self.character_greeting_shown = false;
234        }
235    }
236
237    /// Clear the active character card
238    pub fn clear_character(&mut self) {
239        self.active_character = None;
240        self.character_greeting_shown = false;
241    }
242
243    /// Get a reference to the active character card
244    pub fn get_character(&self) -> Option<&CharacterCard> {
245        self.active_character.as_ref()
246    }
247
248    /// Check if the character greeting should be shown
249    pub fn should_show_greeting(&self) -> bool {
250        if let Some(character) = &self.active_character {
251            !self.character_greeting_shown && !character.data.first_mes.trim().is_empty()
252        } else {
253            false
254        }
255    }
256
257    /// Mark the character greeting as shown
258    pub fn mark_greeting_shown(&mut self) {
259        self.character_greeting_shown = true;
260    }
261
262    #[cfg(test)]
263    pub fn for_test(provider_name: &str, model: &str) -> Self {
264        Self {
265            client: Client::new(),
266            model: model.to_string(),
267            api_key: "test-api-key".to_string(),
268            base_url: "https://example.invalid".to_string(),
269            provider_name: provider_name.to_string(),
270            provider_display_name: provider_name.to_string(),
271            logging: LoggingState::new(None).expect("test logging"),
272            stream_cancel_token: None,
273            current_stream_id: 0,
274            last_retry_time: Instant::now(),
275            retrying_message_index: None,
276            is_refining: false,
277            original_refining_content: None,
278            last_refine_prompt: None,
279            refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
280            refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
281            startup_env_only: false,
282            mcp_disabled: false,
283            active_character: None,
284            character_greeting_shown: false,
285            has_received_assistant_message: false,
286            tool_pipeline: ToolPipelineState::default(),
287            mcp_init: McpInitState::default(),
288            active_assistant_message_index: None,
289            mcp_tools_enabled: false,
290            mcp_tools_unsupported: false,
291        }
292    }
293}
294
295impl ToolPipelineState {
296    pub fn reset(&mut self) {
297        self.pending_tool_calls.clear();
298        self.pending_tool_queue.clear();
299        self.active_tool_request = None;
300        self.pending_sampling_queue.clear();
301        self.active_sampling_request = None;
302        self.tool_call_records.clear();
303        self.tool_results.clear();
304        self.continuation_messages = None;
305    }
306
307    pub fn advance_tool_queue(&mut self) -> Option<&ToolCallRequest> {
308        let request = self.pending_tool_queue.pop_front()?;
309        self.active_tool_request = Some(request);
310        self.active_tool_request.as_ref()
311    }
312
313    pub fn advance_sampling_queue(&mut self) -> Option<&McpSamplingRequest> {
314        let request = self.pending_sampling_queue.pop_front()?;
315        self.active_sampling_request = Some(request);
316        self.active_sampling_request.as_ref()
317    }
318
319    pub fn record_result(
320        &mut self,
321        record: ToolResultRecord,
322        payload: Option<ToolPayloadHistoryEntry>,
323    ) {
324        self.tool_result_history.push(record);
325        if let Some(payload) = payload {
326            self.tool_payload_history.push(payload);
327        }
328    }
329
330    pub fn prune_for_assistant_index(&mut self, index: usize) {
331        self.prune_records(|candidate| candidate == index);
332    }
333
334    pub fn prune_from_index(&mut self, start: usize) {
335        self.prune_records(|candidate| candidate >= start);
336    }
337
338    pub fn clear_server_records(&mut self, server_id: &str) {
339        self.tool_result_history.retain(|record| {
340            record
341                .server_id
342                .as_deref()
343                .map(|id| !id.eq_ignore_ascii_case(server_id))
344                .unwrap_or(true)
345        });
346        self.tool_payload_history.retain(|entry| {
347            entry
348                .server_id
349                .as_deref()
350                .map(|id| !id.eq_ignore_ascii_case(server_id))
351                .unwrap_or(true)
352        });
353    }
354
355    pub fn set_continuation(&mut self, messages: Vec<ChatMessage>, base: Vec<ChatMessage>) {
356        self.continuation_messages = Some(StreamContinuation {
357            api_messages: messages,
358            api_messages_base: base,
359        });
360    }
361
362    pub fn take_continuation(&mut self) -> Option<StreamContinuation> {
363        self.continuation_messages.take()
364    }
365
366    fn prune_records<F>(&mut self, predicate: F)
367    where
368        F: Fn(usize) -> bool,
369    {
370        self.tool_result_history.retain(|record| {
371            record
372                .assistant_message_index
373                .map(|idx| !predicate(idx))
374                .unwrap_or(true)
375        });
376        self.tool_payload_history.retain(|entry| {
377            entry
378                .assistant_message_index
379                .map(|idx| !predicate(idx))
380                .unwrap_or(true)
381        });
382    }
383}
384
385impl McpInitState {
386    pub fn begin(&mut self) {
387        self.in_progress = true;
388        self.complete = false;
389    }
390
391    pub fn complete(&mut self) -> Option<String> {
392        self.in_progress = false;
393        self.complete = true;
394        self.deferred_message.take()
395    }
396
397    pub fn should_defer(&self) -> bool {
398        self.in_progress && !self.complete
399    }
400
401    pub fn reset(&mut self) {
402        self.in_progress = false;
403        self.complete = false;
404        self.deferred_message = None;
405    }
406}
407
408/// Result of attempting to load a character during session initialization.
409#[derive(Debug)]
410pub(crate) struct CharacterLoadOutcome {
411    pub character: Option<CharacterCard>,
412    pub errors: Vec<String>,
413}
414
415pub fn exit_with_provider_resolution_error(err: &ProviderResolutionError) -> ! {
416    eprintln!("{}", err);
417    let fixes = err.quick_fixes();
418    if !fixes.is_empty() {
419        eprintln!();
420        eprintln!("💡 Quick fixes:");
421        for fix in fixes {
422            eprintln!("  • {fix}");
423        }
424    }
425    std::process::exit(err.exit_code());
426}
427
428pub fn exit_if_env_only_missing_env(env_only: bool) {
429    if env_only && std::env::var("OPENAI_API_KEY").is_err() {
430        eprintln!("❌ --env used but OPENAI_API_KEY is not set");
431        std::process::exit(2);
432    }
433}
434
435/// Load character card for session initialization
436/// Priority: CLI flag > default for provider/model > None
437pub(crate) fn load_character_for_session(
438    cli_character: Option<&str>,
439    provider: &str,
440    model: &str,
441    config: &Config,
442    character_service: &mut CharacterService,
443) -> Result<CharacterLoadOutcome, Box<dyn std::error::Error>> {
444    // If CLI character is specified, use it (highest priority)
445    if let Some(character_name) = cli_character {
446        let card = character_service
447            .resolve(character_name)
448            .map_err(|err| Box::new(err) as Box<dyn std::error::Error>)?;
449        return Ok(CharacterLoadOutcome {
450            character: Some(card),
451            errors: Vec::new(),
452        });
453    }
454
455    // Otherwise, check for default character for this provider/model
456    let mut errors = Vec::new();
457    match character_service.load_default_for_session(provider, model, config) {
458        Ok(Some((_name, card))) => {
459            return Ok(CharacterLoadOutcome {
460                character: Some(card),
461                errors,
462            })
463        }
464        Ok(None) => {}
465        Err(err) => {
466            if let Some(default_character) = config.get_default_character(provider, model) {
467                errors.push(format!(
468                    "Failed to load default character '{}' for {}:{}: {}",
469                    default_character, provider, model, err
470                ));
471            } else {
472                errors.push(format!(
473                    "Failed to load default character for {}:{}: {}",
474                    provider, model, err
475                ));
476            }
477        }
478    }
479
480    // No character specified or found
481    Ok(CharacterLoadOutcome {
482        character: None,
483        errors,
484    })
485}
486
487pub(crate) fn initialize_logging(
488    log_file: Option<String>,
489) -> Result<LoggingState, Box<dyn std::error::Error>> {
490    let mut logging = LoggingState::new(log_file.clone())?;
491    if let Some(log_path) = log_file {
492        if let Err(e) = logging.set_log_file(log_path.clone()) {
493            eprintln!(
494                "Warning: Failed to enable startup logging ({}): {}",
495                log_path, e
496            );
497        }
498    }
499    Ok(logging)
500}
501
502fn theme_from_appearance(appearance: Appearance) -> Theme {
503    match appearance {
504        Appearance::Light => Theme::light(),
505        Appearance::Dark => Theme::dark_default(),
506    }
507}
508
509pub(crate) fn resolve_theme(config: &Config) -> Theme {
510    let resolved_theme = match &config.theme {
511        Some(name) => {
512            if let Some(ct) = config.get_custom_theme(name) {
513                Theme::from_spec(&theme_spec_from_custom(ct))
514            } else if let Some(spec) = find_builtin_theme(name) {
515                Theme::from_spec(&spec)
516            } else {
517                Theme::from_name(name)
518            }
519        }
520        None => detect_preferred_appearance()
521            .map(theme_from_appearance)
522            .unwrap_or_else(Theme::dark_default),
523    };
524
525    quantize_theme_for_current_terminal(resolved_theme)
526}
527
528pub(crate) async fn prepare_with_auth(
529    input: PrepareWithAuthInput<'_>,
530) -> Result<SessionBootstrap, Box<dyn std::error::Error>> {
531    let PrepareWithAuthInput {
532        model,
533        log_file,
534        provider,
535        env_only,
536        config,
537        pre_resolved_session,
538        character,
539        character_service,
540    } = input;
541
542    let session = if let Some(session) = pre_resolved_session {
543        session
544    } else if env_only {
545        resolve_env_session().map_err(|err| Box::new(err) as Box<dyn std::error::Error>)?
546    } else {
547        let auth_manager = AuthManager::new()?;
548        match resolve_session(&auth_manager, config, provider.as_deref()) {
549            Ok(session) => session,
550            Err(ResolveSessionError::Provider(err)) => return Err(Box::new(err)),
551            Err(ResolveSessionError::Source(err)) => return Err(err),
552        }
553    };
554
555    let (api_key, base_url, provider_name, provider_display_name) = session.into_tuple();
556
557    let final_model = if model != "default" {
558        model
559    } else if let Some(default_model) = config.get_default_model(&provider_name) {
560        default_model.clone()
561    } else {
562        String::new()
563    };
564
565    let _api_endpoint = construct_api_url(&base_url, "chat/completions");
566
567    let logging = initialize_logging(log_file)?;
568    let resolved_theme = resolve_theme(config);
569
570    // Load character card if specified via CLI or config
571    let CharacterLoadOutcome {
572        character: active_character,
573        errors: startup_errors,
574    } = load_character_for_session(
575        character.as_deref(),
576        &provider_name,
577        &final_model,
578        config,
579        character_service,
580    )?;
581
582    let session = SessionContext {
583        client: Client::new(),
584        model: final_model,
585        api_key,
586        base_url,
587        provider_name: provider_name.to_string(),
588        provider_display_name,
589        logging,
590        stream_cancel_token: None,
591        current_stream_id: 0,
592        last_retry_time: Instant::now(),
593        retrying_message_index: None,
594        is_refining: false,
595        original_refining_content: None,
596        last_refine_prompt: None,
597        refine_instructions: config.refine_instructions().into_owned(),
598        refine_prefix: config.refine_prefix().into_owned(),
599        startup_env_only: false,
600        mcp_disabled: false,
601        active_character,
602        character_greeting_shown: false,
603        has_received_assistant_message: false,
604        tool_pipeline: ToolPipelineState::default(),
605        mcp_init: McpInitState::default(),
606        active_assistant_message_index: None,
607        mcp_tools_enabled: false,
608        mcp_tools_unsupported: false,
609    };
610
611    Ok(SessionBootstrap {
612        session,
613        theme: resolved_theme,
614        startup_requires_provider: false,
615        startup_errors,
616    })
617}
618
619pub(crate) async fn prepare_uninitialized(
620    log_file: Option<String>,
621    _character_service: &mut CharacterService,
622) -> Result<UninitializedSessionBootstrap, Box<dyn std::error::Error>> {
623    let config = Config::load()?;
624
625    let logging = initialize_logging(log_file)?;
626    let resolved_theme = resolve_theme(&config);
627
628    let session = SessionContext {
629        client: Client::new(),
630        model: String::new(),
631        api_key: String::new(),
632        base_url: String::new(),
633        provider_name: String::new(),
634        provider_display_name: "(no provider selected)".to_string(),
635        logging,
636        stream_cancel_token: None,
637        current_stream_id: 0,
638        last_retry_time: Instant::now(),
639        retrying_message_index: None,
640        is_refining: false,
641        original_refining_content: None,
642        last_refine_prompt: None,
643        refine_instructions: config.refine_instructions().into_owned(),
644        refine_prefix: config.refine_prefix().into_owned(),
645        startup_env_only: false,
646        mcp_disabled: false,
647        active_character: None,
648        character_greeting_shown: false,
649        has_received_assistant_message: false,
650        tool_pipeline: ToolPipelineState::default(),
651        mcp_init: McpInitState::default(),
652        active_assistant_message_index: None,
653        mcp_tools_enabled: false,
654        mcp_tools_unsupported: false,
655    };
656
657    Ok(UninitializedSessionBootstrap {
658        session,
659        theme: resolved_theme,
660        config,
661        startup_requires_provider: true,
662    })
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use crate::core::config::data::Config;
669    use crate::core::providers::ProviderSession;
670    use crate::utils::test_utils::TestEnvVarGuard;
671    use tempfile::tempdir;
672
673    #[test]
674    fn theme_from_appearance_matches_light_theme() {
675        let theme = theme_from_appearance(Appearance::Light);
676        assert_eq!(theme.background_color, Theme::light().background_color);
677    }
678
679    #[test]
680    fn theme_from_appearance_matches_dark_theme() {
681        let theme = theme_from_appearance(Appearance::Dark);
682        assert_eq!(
683            theme.background_color,
684            Theme::dark_default().background_color
685        );
686    }
687
688    #[test]
689    fn resolve_theme_prefers_configured_theme() {
690        let config = Config {
691            theme: Some("light".to_string()),
692            ..Default::default()
693        };
694
695        let resolved_theme = resolve_theme(&config);
696        let expected_theme = quantize_theme_for_current_terminal(Theme::light());
697        assert_eq!(
698            resolved_theme.background_color,
699            expected_theme.background_color
700        );
701    }
702
703    #[test]
704    fn prepare_with_auth_uses_pre_resolved_session() {
705        let provider_session = ProviderSession {
706            api_key: "test-key".to_string(),
707            base_url: "https://example.invalid".to_string(),
708            provider_id: "test-provider".to_string(),
709            provider_display_name: "Test Provider".to_string(),
710        };
711
712        let config = Config::default();
713        let runtime = tokio::runtime::Runtime::new().expect("runtime");
714        let mut service = crate::character::CharacterService::new();
715
716        let bootstrap = runtime
717            .block_on(super::prepare_with_auth(super::PrepareWithAuthInput {
718                model: "default".to_string(),
719                log_file: None,
720                provider: None,
721                env_only: false,
722                config: &config,
723                pre_resolved_session: Some(provider_session.clone()),
724                character: None,
725                character_service: &mut service,
726            }))
727            .expect("prepare_with_auth");
728
729        assert_eq!(bootstrap.session.api_key, provider_session.api_key);
730        assert_eq!(bootstrap.session.base_url, provider_session.base_url);
731        assert_eq!(
732            bootstrap.session.provider_name,
733            provider_session.provider_id
734        );
735        assert_eq!(
736            bootstrap.session.provider_display_name,
737            provider_session.provider_display_name
738        );
739        assert!(!bootstrap.startup_requires_provider);
740        assert!(!bootstrap.session.startup_env_only);
741        assert!(bootstrap.session.active_character.is_none());
742        assert!(!bootstrap.session.character_greeting_shown);
743    }
744
745    #[test]
746    fn prepare_with_auth_uses_env_session_when_env_only() {
747        let mut env_guard = TestEnvVarGuard::new();
748        env_guard.set_var("OPENAI_API_KEY", "sk-env");
749        env_guard.set_var("OPENAI_BASE_URL", "https://example.com/v1");
750
751        let config = Config::default();
752        let runtime = tokio::runtime::Runtime::new().expect("runtime");
753        let mut service = crate::character::CharacterService::new();
754
755        let bootstrap = runtime
756            .block_on(super::prepare_with_auth(super::PrepareWithAuthInput {
757                model: "default".to_string(),
758                log_file: None,
759                provider: None,
760                env_only: true,
761                config: &config,
762                pre_resolved_session: None,
763                character: None,
764                character_service: &mut service,
765            }))
766            .expect("prepare_with_auth");
767
768        assert_eq!(bootstrap.session.api_key, "sk-env");
769        assert_eq!(bootstrap.session.base_url, "https://example.com/v1");
770        assert_eq!(bootstrap.session.provider_name, "openai-compatible");
771        assert_eq!(bootstrap.session.provider_display_name, "OpenAI-compatible");
772    }
773
774    #[test]
775    fn initialize_logging_with_file_writes_initial_entry() {
776        let temp_dir = tempdir().expect("tempdir");
777        let log_path = temp_dir.path().join("startup.log");
778        let log_file = log_path.to_string_lossy().to_string();
779
780        let logging = initialize_logging(Some(log_file.clone())).expect("logging initialized");
781        logging
782            .log_message("Hello from startup")
783            .expect("log message");
784
785        let contents = std::fs::read_to_string(&log_path).expect("read log file");
786        // "## Logging started" is an app message added by the command handler, not by initialize_logging
787        assert!(contents.contains("Hello from startup"));
788    }
789
790    #[test]
791    fn tool_pipeline_reset_clears_active_and_queues() {
792        let mut pipeline = ToolPipelineState::default();
793        pipeline.pending_tool_queue.push_back(ToolCallRequest {
794            server_id: "s".into(),
795            tool_name: "t".into(),
796            arguments: None,
797            raw_arguments: "{}".into(),
798            tool_call_id: Some("call".into()),
799        });
800        pipeline.active_tool_request = pipeline.pending_tool_queue.front().cloned();
801        pipeline.set_continuation(Vec::new(), Vec::new());
802
803        pipeline.reset();
804
805        assert!(pipeline.pending_tool_queue.is_empty());
806        assert!(pipeline.pending_sampling_queue.is_empty());
807        assert!(pipeline.active_tool_request.is_none());
808        assert!(pipeline.active_sampling_request.is_none());
809        assert!(pipeline.continuation_messages.is_none());
810    }
811
812    #[test]
813    fn tool_pipeline_advance_tool_queue_handles_empty_and_item() {
814        let mut pipeline = ToolPipelineState::default();
815        assert!(pipeline.advance_tool_queue().is_none());
816
817        pipeline.pending_tool_queue.push_back(ToolCallRequest {
818            server_id: "s".into(),
819            tool_name: "t".into(),
820            arguments: None,
821            raw_arguments: "{}".into(),
822            tool_call_id: None,
823        });
824
825        let request = pipeline.advance_tool_queue().expect("advanced");
826        assert_eq!(request.tool_name, "t");
827        assert!(pipeline.pending_tool_queue.is_empty());
828    }
829
830    #[test]
831    fn tool_pipeline_prune_for_assistant_index_removes_matching_records() {
832        let mut pipeline = ToolPipelineState::default();
833        pipeline.tool_result_history.push(ToolResultRecord {
834            tool_name: "keep".into(),
835            server_name: None,
836            server_id: Some("server".into()),
837            status: ToolResultStatus::Success,
838            failure_kind: None,
839            content: "ok".into(),
840            summary: "ok".into(),
841            tool_call_id: Some("keep".into()),
842            raw_arguments: None,
843            assistant_message_index: Some(1),
844        });
845        pipeline.tool_result_history.push(ToolResultRecord {
846            tool_name: "drop".into(),
847            server_name: None,
848            server_id: Some("server".into()),
849            status: ToolResultStatus::Error,
850            failure_kind: Some(ToolFailureKind::ToolError),
851            content: "fail".into(),
852            summary: "fail".into(),
853            tool_call_id: Some("drop".into()),
854            raw_arguments: None,
855            assistant_message_index: Some(3),
856        });
857
858        pipeline.prune_for_assistant_index(3);
859
860        assert_eq!(pipeline.tool_result_history.len(), 1);
861        assert_eq!(pipeline.tool_result_history[0].tool_name, "keep");
862    }
863
864    #[test]
865    fn tool_pipeline_continuation_round_trip_and_drain() {
866        let mut pipeline = ToolPipelineState::default();
867        pipeline.set_continuation(Vec::new(), Vec::new());
868
869        assert!(pipeline.take_continuation().is_some());
870        assert!(pipeline.take_continuation().is_none());
871    }
872
873    #[test]
874    fn mcp_init_state_complete_returns_message_and_clears_progress() {
875        let mut state = McpInitState::default();
876        state.begin();
877        state.deferred_message = Some("hello".into());
878
879        let deferred = state.complete();
880
881        assert_eq!(deferred.as_deref(), Some("hello"));
882        assert!(state.complete);
883        assert!(!state.in_progress);
884        assert!(state.deferred_message.is_none());
885    }
886
887    #[test]
888    fn mcp_init_state_should_defer_only_while_in_progress() {
889        let mut state = McpInitState::default();
890        assert!(!state.should_defer());
891
892        state.begin();
893        assert!(state.should_defer());
894
895        state.complete();
896        assert!(!state.should_defer());
897    }
898
899    #[test]
900    fn mcp_init_state_reset_restores_default() {
901        let mut state = McpInitState {
902            in_progress: true,
903            complete: true,
904            deferred_message: Some("queued".into()),
905        };
906
907        state.reset();
908
909        assert!(!state.in_progress);
910        assert!(!state.complete);
911        assert!(state.deferred_message.is_none());
912    }
913    #[test]
914    fn session_context_set_character() {
915        use crate::character::card::{CharacterCard, CharacterData};
916
917        let mut session = SessionContext {
918            client: Client::new(),
919            model: String::new(),
920            api_key: String::new(),
921            base_url: String::new(),
922            provider_name: String::new(),
923            provider_display_name: String::new(),
924            logging: LoggingState::new(None).unwrap(),
925            stream_cancel_token: None,
926            current_stream_id: 0,
927            last_retry_time: Instant::now(),
928            retrying_message_index: None,
929            is_refining: false,
930            original_refining_content: None,
931            last_refine_prompt: None,
932            refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
933            refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
934            startup_env_only: false,
935            mcp_disabled: false,
936            active_character: None,
937            character_greeting_shown: false,
938            has_received_assistant_message: false,
939            tool_pipeline: ToolPipelineState::default(),
940            mcp_init: McpInitState::default(),
941            active_assistant_message_index: None,
942            mcp_tools_enabled: false,
943            mcp_tools_unsupported: false,
944        };
945
946        let card = CharacterCard {
947            spec: "chara_card_v2".to_string(),
948            spec_version: "2.0".to_string(),
949            data: CharacterData {
950                name: "Test".to_string(),
951                description: "Test character".to_string(),
952                personality: "Friendly".to_string(),
953                scenario: "Testing".to_string(),
954                first_mes: "Hello!".to_string(),
955                mes_example: String::new(),
956                creator_notes: None,
957                system_prompt: None,
958                post_history_instructions: None,
959                alternate_greetings: None,
960                tags: None,
961                creator: None,
962                character_version: None,
963            },
964        };
965
966        session.set_character(card.clone());
967        assert!(session.active_character.is_some());
968        assert_eq!(session.get_character().unwrap().data.name, "Test");
969        assert!(!session.character_greeting_shown);
970    }
971
972    #[test]
973    fn session_context_clear_character() {
974        use crate::character::card::{CharacterCard, CharacterData};
975
976        let mut session = SessionContext {
977            client: Client::new(),
978            model: String::new(),
979            api_key: String::new(),
980            base_url: String::new(),
981            provider_name: String::new(),
982            provider_display_name: String::new(),
983            logging: LoggingState::new(None).unwrap(),
984            stream_cancel_token: None,
985            current_stream_id: 0,
986            last_retry_time: Instant::now(),
987            retrying_message_index: None,
988            is_refining: false,
989            original_refining_content: None,
990            last_refine_prompt: None,
991            refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
992            refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
993            startup_env_only: false,
994            mcp_disabled: false,
995            active_character: Some(CharacterCard {
996                spec: "chara_card_v2".to_string(),
997                spec_version: "2.0".to_string(),
998                data: CharacterData {
999                    name: "Test".to_string(),
1000                    description: "Test character".to_string(),
1001                    personality: "Friendly".to_string(),
1002                    scenario: "Testing".to_string(),
1003                    first_mes: "Hello!".to_string(),
1004                    mes_example: String::new(),
1005                    creator_notes: None,
1006                    system_prompt: None,
1007                    post_history_instructions: None,
1008                    alternate_greetings: None,
1009                    tags: None,
1010                    creator: None,
1011                    character_version: None,
1012                },
1013            }),
1014            character_greeting_shown: true,
1015            has_received_assistant_message: false,
1016            tool_pipeline: ToolPipelineState::default(),
1017            mcp_init: McpInitState::default(),
1018            active_assistant_message_index: None,
1019            mcp_tools_enabled: false,
1020            mcp_tools_unsupported: false,
1021        };
1022
1023        session.clear_character();
1024        assert!(session.active_character.is_none());
1025        assert!(!session.character_greeting_shown);
1026    }
1027
1028    #[test]
1029    fn session_context_should_show_greeting() {
1030        use crate::character::card::{CharacterCard, CharacterData};
1031
1032        let mut session = SessionContext {
1033            client: Client::new(),
1034            model: String::new(),
1035            api_key: String::new(),
1036            base_url: String::new(),
1037            provider_name: String::new(),
1038            provider_display_name: String::new(),
1039            logging: LoggingState::new(None).unwrap(),
1040            stream_cancel_token: None,
1041            current_stream_id: 0,
1042            last_retry_time: Instant::now(),
1043            retrying_message_index: None,
1044            is_refining: false,
1045            original_refining_content: None,
1046            last_refine_prompt: None,
1047            refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
1048            refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
1049            startup_env_only: false,
1050            mcp_disabled: false,
1051            active_character: Some(CharacterCard {
1052                spec: "chara_card_v2".to_string(),
1053                spec_version: "2.0".to_string(),
1054                data: CharacterData {
1055                    name: "Test".to_string(),
1056                    description: "Test character".to_string(),
1057                    personality: "Friendly".to_string(),
1058                    scenario: "Testing".to_string(),
1059                    first_mes: "Hello!".to_string(),
1060                    mes_example: String::new(),
1061                    creator_notes: None,
1062                    system_prompt: None,
1063                    post_history_instructions: None,
1064                    alternate_greetings: None,
1065                    tags: None,
1066                    creator: None,
1067                    character_version: None,
1068                },
1069            }),
1070            character_greeting_shown: false,
1071            has_received_assistant_message: false,
1072            tool_pipeline: ToolPipelineState::default(),
1073            mcp_init: McpInitState::default(),
1074            active_assistant_message_index: None,
1075            mcp_tools_enabled: false,
1076            mcp_tools_unsupported: false,
1077        };
1078
1079        // Should show greeting when character is active and greeting not shown
1080        assert!(session.should_show_greeting());
1081
1082        // Should not show greeting after marking as shown
1083        session.mark_greeting_shown();
1084        assert!(!session.should_show_greeting());
1085    }
1086
1087    #[test]
1088    fn session_context_should_not_show_empty_greeting() {
1089        use crate::character::card::{CharacterCard, CharacterData};
1090
1091        let session = SessionContext {
1092            client: Client::new(),
1093            model: String::new(),
1094            api_key: String::new(),
1095            base_url: String::new(),
1096            provider_name: String::new(),
1097            provider_display_name: String::new(),
1098            logging: LoggingState::new(None).unwrap(),
1099            stream_cancel_token: None,
1100            current_stream_id: 0,
1101            last_retry_time: Instant::now(),
1102            retrying_message_index: None,
1103            is_refining: false,
1104            original_refining_content: None,
1105            last_refine_prompt: None,
1106            refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
1107            refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
1108            startup_env_only: false,
1109            mcp_disabled: false,
1110            active_character: Some(CharacterCard {
1111                spec: "chara_card_v2".to_string(),
1112                spec_version: "2.0".to_string(),
1113                data: CharacterData {
1114                    name: "Test".to_string(),
1115                    description: "Test character".to_string(),
1116                    personality: "Friendly".to_string(),
1117                    scenario: "Testing".to_string(),
1118                    first_mes: "   ".to_string(), // Empty/whitespace greeting
1119                    mes_example: String::new(),
1120                    creator_notes: None,
1121                    system_prompt: None,
1122                    post_history_instructions: None,
1123                    alternate_greetings: None,
1124                    tags: None,
1125                    creator: None,
1126                    character_version: None,
1127                },
1128            }),
1129            character_greeting_shown: false,
1130            has_received_assistant_message: false,
1131            tool_pipeline: ToolPipelineState::default(),
1132            mcp_init: McpInitState::default(),
1133            active_assistant_message_index: None,
1134            mcp_tools_enabled: false,
1135            mcp_tools_unsupported: false,
1136        };
1137
1138        // Should not show empty/whitespace greeting
1139        assert!(!session.should_show_greeting());
1140    }
1141
1142    #[test]
1143    fn load_character_for_session_no_character() {
1144        let config = Config::default();
1145        let mut service = crate::character::CharacterService::new();
1146        let outcome =
1147            super::load_character_for_session(None, "openai", "gpt-4", &config, &mut service)
1148                .expect("load_character_for_session");
1149
1150        assert!(outcome.character.is_none());
1151        assert!(outcome.errors.is_empty());
1152    }
1153
1154    #[test]
1155    fn load_character_for_session_cli_takes_precedence() {
1156        use crate::character::card::{CharacterCard, CharacterData};
1157        use std::collections::HashMap;
1158        use std::fs;
1159
1160        let temp_dir = tempdir().expect("tempdir");
1161        let cards_dir = temp_dir.path().join("cards");
1162        fs::create_dir_all(&cards_dir).expect("create cards dir");
1163
1164        // Create a test card
1165        let card = CharacterCard {
1166            spec: "chara_card_v2".to_string(),
1167            spec_version: "2.0".to_string(),
1168            data: CharacterData {
1169                name: "TestChar".to_string(),
1170                description: "Test".to_string(),
1171                personality: "Friendly".to_string(),
1172                scenario: "Testing".to_string(),
1173                first_mes: "Hello!".to_string(),
1174                mes_example: String::new(),
1175                creator_notes: None,
1176                system_prompt: None,
1177                post_history_instructions: None,
1178                alternate_greetings: None,
1179                tags: None,
1180                creator: None,
1181                character_version: None,
1182            },
1183        };
1184
1185        let card_path = cards_dir.join("testchar.json");
1186        let card_json = serde_json::to_string(&card).expect("serialize card");
1187        fs::write(&card_path, card_json).expect("write card");
1188
1189        // Create config with a different default character
1190        let mut default_chars = HashMap::new();
1191        let mut openai_models = HashMap::new();
1192        openai_models.insert("gpt-4".to_string(), "other-char".to_string());
1193        default_chars.insert("openai".to_string(), openai_models);
1194
1195        let config = Config {
1196            default_characters: default_chars,
1197            ..Default::default()
1198        };
1199
1200        // CLI character should take precedence (but we can't test this without
1201        // setting up the full cards directory structure, so we'll just verify
1202        // the logic exists in the function)
1203        // This test verifies the function signature and basic behavior
1204        let mut service = crate::character::CharacterService::new();
1205        let result = super::load_character_for_session(
1206            Some(card_path.to_str().unwrap()),
1207            "openai",
1208            "gpt-4",
1209            &config,
1210            &mut service,
1211        );
1212        let outcome = result.expect("cli load");
1213        assert!(outcome.errors.is_empty());
1214        assert_eq!(
1215            outcome.character.expect("character loaded").data.name,
1216            "TestChar"
1217        );
1218    }
1219
1220    #[test]
1221    fn load_character_for_session_filepath_fallback() {
1222        use crate::character::card::{CharacterCard, CharacterData};
1223        use std::fs;
1224
1225        let temp_dir = tempdir().expect("tempdir");
1226
1227        // Create a character card file outside the cards directory
1228        let card = CharacterCard {
1229            spec: "chara_card_v2".to_string(),
1230            spec_version: "2.0".to_string(),
1231            data: CharacterData {
1232                name: "FilePathChar".to_string(),
1233                description: "Loaded from file path".to_string(),
1234                personality: "Friendly".to_string(),
1235                scenario: "Testing".to_string(),
1236                first_mes: "Hello from file!".to_string(),
1237                mes_example: String::new(),
1238                creator_notes: None,
1239                system_prompt: None,
1240                post_history_instructions: None,
1241                alternate_greetings: None,
1242                tags: None,
1243                creator: None,
1244                character_version: None,
1245            },
1246        };
1247
1248        let card_path = temp_dir.path().join("external_card.json");
1249        let card_json = serde_json::to_string(&card).expect("serialize card");
1250        fs::write(&card_path, card_json).expect("write card");
1251
1252        let config = Config::default();
1253        let mut service = crate::character::CharacterService::new();
1254
1255        // Load character by file path (should work as fallback)
1256        let result = super::load_character_for_session(
1257            Some(card_path.to_str().unwrap()),
1258            "openai",
1259            "gpt-4",
1260            &config,
1261            &mut service,
1262        );
1263        assert!(result.is_ok());
1264        let outcome = result.unwrap();
1265        assert!(outcome.character.is_some());
1266        assert_eq!(outcome.character.unwrap().data.name, "FilePathChar");
1267        assert!(outcome.errors.is_empty());
1268    }
1269
1270    #[test]
1271    fn load_character_for_session_cards_dir_priority() {
1272        use crate::character::card::{CharacterCard, CharacterData};
1273        use std::fs;
1274
1275        let temp_dir = tempdir().expect("tempdir");
1276
1277        // Create a character card file in current directory with name "data"
1278        let wrong_card = CharacterCard {
1279            spec: "chara_card_v2".to_string(),
1280            spec_version: "2.0".to_string(),
1281            data: CharacterData {
1282                name: "WrongChar".to_string(),
1283                description: "Should not be loaded".to_string(),
1284                personality: "Wrong".to_string(),
1285                scenario: "Wrong".to_string(),
1286                first_mes: "Wrong!".to_string(),
1287                mes_example: String::new(),
1288                creator_notes: None,
1289                system_prompt: None,
1290                post_history_instructions: None,
1291                alternate_greetings: None,
1292                tags: None,
1293                creator: None,
1294                character_version: None,
1295            },
1296        };
1297
1298        let wrong_path = temp_dir.path().join("data.json");
1299        let wrong_json = serde_json::to_string(&wrong_card).expect("serialize card");
1300        fs::write(&wrong_path, wrong_json).expect("write card");
1301
1302        let config = Config::default();
1303
1304        // Try to load character named "data" - should fail because it's not in cards dir
1305        // and we're not providing the full path
1306        let mut service = crate::character::CharacterService::new();
1307        let result = super::load_character_for_session(
1308            Some("data"),
1309            "openai",
1310            "gpt-4",
1311            &config,
1312            &mut service,
1313        );
1314
1315        // Should fail because "data" is not found in cards directory
1316        // and "data" as a relative path doesn't exist
1317        assert!(result.is_err());
1318    }
1319
1320    #[test]
1321    fn session_context_get_character_returns_none_initially() {
1322        let session = SessionContext {
1323            client: Client::new(),
1324            model: String::new(),
1325            api_key: String::new(),
1326            base_url: String::new(),
1327            provider_name: String::new(),
1328            provider_display_name: String::new(),
1329            logging: LoggingState::new(None).unwrap(),
1330            stream_cancel_token: None,
1331            current_stream_id: 0,
1332            last_retry_time: Instant::now(),
1333            retrying_message_index: None,
1334            is_refining: false,
1335            original_refining_content: None,
1336            last_refine_prompt: None,
1337            refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
1338            refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
1339            startup_env_only: false,
1340            mcp_disabled: false,
1341            active_character: None,
1342            character_greeting_shown: false,
1343            has_received_assistant_message: false,
1344            tool_pipeline: ToolPipelineState::default(),
1345            mcp_init: McpInitState::default(),
1346            active_assistant_message_index: None,
1347            mcp_tools_enabled: false,
1348            mcp_tools_unsupported: false,
1349        };
1350
1351        assert!(session.get_character().is_none());
1352        assert!(!session.should_show_greeting());
1353    }
1354
1355    #[test]
1356    fn session_context_greeting_lifecycle() {
1357        use crate::character::card::{CharacterCard, CharacterData};
1358
1359        let mut session = SessionContext {
1360            client: Client::new(),
1361            model: String::new(),
1362            api_key: String::new(),
1363            base_url: String::new(),
1364            provider_name: String::new(),
1365            provider_display_name: String::new(),
1366            logging: LoggingState::new(None).unwrap(),
1367            stream_cancel_token: None,
1368            current_stream_id: 0,
1369            last_retry_time: Instant::now(),
1370            retrying_message_index: None,
1371            is_refining: false,
1372            original_refining_content: None,
1373            last_refine_prompt: None,
1374            refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
1375            refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
1376            startup_env_only: false,
1377            mcp_disabled: false,
1378            active_character: None,
1379            character_greeting_shown: false,
1380            has_received_assistant_message: false,
1381            tool_pipeline: ToolPipelineState::default(),
1382            mcp_init: McpInitState::default(),
1383            active_assistant_message_index: None,
1384            mcp_tools_enabled: false,
1385            mcp_tools_unsupported: false,
1386        };
1387
1388        // Initially no greeting
1389        assert!(!session.should_show_greeting());
1390
1391        // Set character with greeting
1392        let card = CharacterCard {
1393            spec: "chara_card_v2".to_string(),
1394            spec_version: "2.0".to_string(),
1395            data: CharacterData {
1396                name: "Test".to_string(),
1397                description: "Test character".to_string(),
1398                personality: "Friendly".to_string(),
1399                scenario: "Testing".to_string(),
1400                first_mes: "Hello there!".to_string(),
1401                mes_example: String::new(),
1402                creator_notes: None,
1403                system_prompt: None,
1404                post_history_instructions: None,
1405                alternate_greetings: None,
1406                tags: None,
1407                creator: None,
1408                character_version: None,
1409            },
1410        };
1411
1412        session.set_character(card);
1413
1414        // Should show greeting now
1415        assert!(session.should_show_greeting());
1416
1417        // Mark as shown
1418        session.mark_greeting_shown();
1419
1420        // Should not show greeting anymore
1421        assert!(!session.should_show_greeting());
1422
1423        // Clear character
1424        session.clear_character();
1425
1426        // Should not show greeting after clearing
1427        assert!(!session.should_show_greeting());
1428    }
1429
1430    #[test]
1431    fn session_context_reselecting_same_character_preserves_greeting_flag() {
1432        use crate::character::card::{CharacterCard, CharacterData};
1433
1434        let mut session = SessionContext {
1435            client: Client::new(),
1436            model: String::new(),
1437            api_key: String::new(),
1438            base_url: String::new(),
1439            provider_name: String::new(),
1440            provider_display_name: String::new(),
1441            logging: LoggingState::new(None).unwrap(),
1442            stream_cancel_token: None,
1443            current_stream_id: 0,
1444            last_retry_time: Instant::now(),
1445            retrying_message_index: None,
1446            is_refining: false,
1447            original_refining_content: None,
1448            last_refine_prompt: None,
1449            refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
1450            refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
1451            startup_env_only: false,
1452            mcp_disabled: false,
1453            active_character: None,
1454            character_greeting_shown: false,
1455            has_received_assistant_message: false,
1456            tool_pipeline: ToolPipelineState::default(),
1457            mcp_init: McpInitState::default(),
1458            active_assistant_message_index: None,
1459            mcp_tools_enabled: false,
1460            mcp_tools_unsupported: false,
1461        };
1462
1463        let card = CharacterCard {
1464            spec: "chara_card_v2".to_string(),
1465            spec_version: "2.0".to_string(),
1466            data: CharacterData {
1467                name: "Test".to_string(),
1468                description: "Test character".to_string(),
1469                personality: "Friendly".to_string(),
1470                scenario: "Testing".to_string(),
1471                first_mes: "Hello there!".to_string(),
1472                mes_example: String::new(),
1473                creator_notes: None,
1474                system_prompt: None,
1475                post_history_instructions: None,
1476                alternate_greetings: None,
1477                tags: None,
1478                creator: None,
1479                character_version: None,
1480            },
1481        };
1482
1483        // Set character and mark greeting as shown
1484        session.set_character(card.clone());
1485        assert!(session.should_show_greeting());
1486        session.mark_greeting_shown();
1487        assert!(!session.should_show_greeting());
1488
1489        // Re-select the same character
1490        session.set_character(card);
1491
1492        // Greeting flag should still be true (greeting already shown)
1493        assert!(!session.should_show_greeting());
1494        assert!(session.character_greeting_shown);
1495    }
1496
1497    #[test]
1498    fn session_context_selecting_different_character_resets_greeting_flag() {
1499        use crate::character::card::{CharacterCard, CharacterData};
1500
1501        let mut session = SessionContext {
1502            client: Client::new(),
1503            model: String::new(),
1504            api_key: String::new(),
1505            base_url: String::new(),
1506            provider_name: String::new(),
1507            provider_display_name: String::new(),
1508            logging: LoggingState::new(None).unwrap(),
1509            stream_cancel_token: None,
1510            current_stream_id: 0,
1511            last_retry_time: Instant::now(),
1512            retrying_message_index: None,
1513            is_refining: false,
1514            original_refining_content: None,
1515            last_refine_prompt: None,
1516            refine_instructions: DEFAULT_REFINE_INSTRUCTIONS.to_string(),
1517            refine_prefix: DEFAULT_REFINE_PREFIX.to_string(),
1518            startup_env_only: false,
1519            mcp_disabled: false,
1520            active_character: None,
1521            character_greeting_shown: false,
1522            has_received_assistant_message: false,
1523            tool_pipeline: ToolPipelineState::default(),
1524            mcp_init: McpInitState::default(),
1525            active_assistant_message_index: None,
1526            mcp_tools_enabled: false,
1527            mcp_tools_unsupported: false,
1528        };
1529
1530        let card1 = CharacterCard {
1531            spec: "chara_card_v2".to_string(),
1532            spec_version: "2.0".to_string(),
1533            data: CharacterData {
1534                name: "Test1".to_string(),
1535                description: "Test character 1".to_string(),
1536                personality: "Friendly".to_string(),
1537                scenario: "Testing".to_string(),
1538                first_mes: "Hello from Test1!".to_string(),
1539                mes_example: String::new(),
1540                creator_notes: None,
1541                system_prompt: None,
1542                post_history_instructions: None,
1543                alternate_greetings: None,
1544                tags: None,
1545                creator: None,
1546                character_version: None,
1547            },
1548        };
1549
1550        let card2 = CharacterCard {
1551            spec: "chara_card_v2".to_string(),
1552            spec_version: "2.0".to_string(),
1553            data: CharacterData {
1554                name: "Test2".to_string(),
1555                description: "Test character 2".to_string(),
1556                personality: "Helpful".to_string(),
1557                scenario: "Testing".to_string(),
1558                first_mes: "Hello from Test2!".to_string(),
1559                mes_example: String::new(),
1560                creator_notes: None,
1561                system_prompt: None,
1562                post_history_instructions: None,
1563                alternate_greetings: None,
1564                tags: None,
1565                creator: None,
1566                character_version: None,
1567            },
1568        };
1569
1570        // Set first character and mark greeting as shown
1571        session.set_character(card1);
1572        session.mark_greeting_shown();
1573        assert!(!session.should_show_greeting());
1574
1575        // Select a different character
1576        session.set_character(card2);
1577
1578        // Greeting flag should be reset (new character, should show greeting)
1579        assert!(session.should_show_greeting());
1580        assert!(!session.character_greeting_shown);
1581    }
1582}