Skip to main content

saya_cli/interactive/
session_commands.rs

1use super::session_state::SessionState;
2use crate::agent::runtime::PromptOverrides;
3use crate::cli::ContractsCommand;
4use crate::slash::SlashCommand;
5use saya_agent::AgentOutput;
6use saya_agent::ApprovalPolicy;
7
8// `Agent(AgentOutput)` carries agent events backed by `serde_json::Value`, so
9// this enum is `PartialEq` but not `Eq`.
10#[derive(Debug, Clone, PartialEq)]
11pub enum SessionAction {
12    Message(String),
13    Agent(AgentOutput),
14    Cancelled,
15    NotImplemented(String),
16    Error(String),
17    History,
18    Resume(String),
19    Schema(bool),
20    Sql(String),
21    Export(String),
22    Chart(String),
23    Explain(String),
24    /// A contract slash command, translated to the same `ContractsCommand` the
25    /// headless `saya contracts` parser produces. The loops hand it to the
26    /// shared `run_contracts` dispatcher — no second parsing or DTO mapping.
27    Contracts(ContractsCommand),
28    Exit,
29}
30
31impl SessionState {
32    pub fn apply(&mut self, command: SlashCommand, available: &[String]) -> SessionAction {
33        match command {
34            SlashCommand::Connect(name) => {
35                if !available.iter().any(|profile| profile == &name) {
36                    return SessionAction::Error(format!("Unknown configured profile: {name}"));
37                }
38                self.profile = Some(name.clone());
39                SessionAction::Message(format!("Selected profile: {name}"))
40            }
41            SlashCommand::Connections => SessionAction::Message(if available.is_empty() {
42                "No configured connection profiles.".into()
43            } else {
44                format!("Profiles: {}", available.join(", "))
45            }),
46            SlashCommand::Include(name) => {
47                if !available.iter().any(|profile| profile == &name) {
48                    return SessionAction::Error(format!("Unknown configured profile: {name}"));
49                }
50                if !self.included_profiles.contains(&name) {
51                    self.included_profiles.push(name.clone());
52                }
53                SessionAction::Message(format!("Included profile: {name}"))
54            }
55            SlashCommand::Exclude(name) => {
56                self.included_profiles.retain(|item| item != &name);
57                SessionAction::Message(format!("Excluded profile: {name}"))
58            }
59            SlashCommand::Provider(value) => {
60                if let Some(value) = value {
61                    if saya_config::AiProvider::parse(&value).is_none() {
62                        return SessionAction::Error(format!(
63                            "Unsupported provider: {value}. Use ollama, openai, openai_compatible, anthropic, or gemini."
64                        ));
65                    }
66                    self.provider = value;
67                    SessionAction::Message(format!("Provider: {}", self.provider))
68                } else {
69                    SessionAction::Message(format!(
70                        "Provider: {} (available: {})",
71                        self.provider,
72                        available_providers().join(", ")
73                    ))
74                }
75            }
76            SlashCommand::Model(value) => {
77                if let Some(value) = value {
78                    self.model = value;
79                    SessionAction::Message(format!("Model: {}", self.model))
80                } else {
81                    let models = known_models(&self.provider);
82                    if models.is_empty() {
83                        SessionAction::Message(format!(
84                            "Model: {} (no suggestions for provider {})",
85                            self.model, self.provider
86                        ))
87                    } else {
88                        SessionAction::Message(format!(
89                            "Model: {}\nKnown models for {}: {}",
90                            self.model,
91                            self.provider,
92                            models.join(", ")
93                        ))
94                    }
95                }
96            }
97            SlashCommand::Privacy(value) => {
98                if let Some(value) = value {
99                    self.allow_data_sharing = value;
100                }
101                SessionAction::Message(format!(
102                    "Cloud data sharing: {}",
103                    if self.allow_data_sharing {
104                        "enabled"
105                    } else {
106                        "disabled"
107                    }
108                ))
109            }
110            SlashCommand::Approvals(value) => {
111                if let Some(value) = value {
112                    self.approval_mode = approval_name(value);
113                }
114                SessionAction::Message(format!("Approval mode: {}", self.approval_mode))
115            }
116            SlashCommand::Schema(refresh) => SessionAction::Schema(refresh),
117            SlashCommand::Sql(query) => SessionAction::Sql(query),
118            SlashCommand::Export(path) => SessionAction::Export(path),
119            SlashCommand::Chart(args) => SessionAction::Chart(args),
120            SlashCommand::Explain(sql) => SessionAction::Explain(sql),
121            SlashCommand::Clear => {
122                self.messages.clear();
123                self.turns.clear();
124                SessionAction::Message("Conversation context cleared.".into())
125            }
126            SlashCommand::History => SessionAction::History,
127            SlashCommand::Sessions => SessionAction::History,
128            SlashCommand::Resume(id) => SessionAction::Resume(id),
129            SlashCommand::Help(topic) => {
130                SessionAction::Message(crate::slash::help_for(topic.as_deref()))
131            }
132            SlashCommand::Contracts(command) => SessionAction::Contracts(command),
133            SlashCommand::Exit => SessionAction::Exit,
134        }
135    }
136
137    pub(crate) fn prompt_overrides(&self) -> PromptOverrides {
138        PromptOverrides {
139            provider: saya_config::AiProvider::parse(&self.provider),
140            model: Some(self.model.clone()),
141            allow_data_sharing: Some(self.allow_data_sharing),
142            profile: self.profile.clone(),
143            included_profiles: self.included_profiles.clone(),
144        }
145    }
146}
147
148fn approval_name(policy: ApprovalPolicy) -> String {
149    match policy {
150        ApprovalPolicy::Ask => "ask",
151        ApprovalPolicy::ReadOnly => "read-only",
152        ApprovalPolicy::Never => "never",
153    }
154    .into()
155}
156
157fn available_providers() -> &'static [&'static str] {
158    &[
159        "ollama",
160        "openai",
161        "openai_compatible",
162        "anthropic",
163        "gemini",
164    ]
165}
166
167/// Curated suggestions for models per provider (convenience suggestions, not an exhaustive or validated list).
168fn known_models(provider: &str) -> &'static [&'static str] {
169    match provider.to_ascii_lowercase().as_str() {
170        "ollama" => &["qwen2.5-coder:14b", "llama3.1", "mistral"],
171        "openai" => &["gpt-4o", "gpt-4o-mini", "o3-mini"],
172        "anthropic" => &["claude-sonnet-4", "claude-opus-4", "claude-3-5-haiku"],
173        "gemini" => &["gemini-2.0-flash", "gemini-1.5-pro"],
174        "openai_compatible" => &[],
175        _ => &[],
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_provider_none_lists_available() {
185        let mut state = SessionState::new("test", None, "gpt-4o");
186        let action = state.apply(SlashCommand::Provider(None), &[]);
187        if let SessionAction::Message(msg) = action {
188            assert!(msg.contains("available:"));
189            assert!(msg.contains("anthropic"));
190        } else {
191            panic!("Expected SessionAction::Message");
192        }
193    }
194
195    #[test]
196    fn test_model_none_lists_known_models_for_ollama() {
197        let mut state = SessionState::new("test", None, "qwen2.5-coder:14b");
198        state.provider = "ollama".into();
199        let action = state.apply(SlashCommand::Model(None), &[]);
200        if let SessionAction::Message(msg) = action {
201            assert!(msg.contains("qwen2.5-coder:14b"));
202        } else {
203            panic!("Expected SessionAction::Message");
204        }
205    }
206
207    #[test]
208    fn test_provider_some_sets_provider_without_available() {
209        let mut state = SessionState::new("test", None, "gpt-4o");
210        let action = state.apply(SlashCommand::Provider(Some("openai".into())), &[]);
211        assert_eq!(state.provider, "openai");
212        if let SessionAction::Message(msg) = action {
213            assert_eq!(msg, "Provider: openai");
214            assert!(!msg.contains("available:"));
215        } else {
216            panic!("Expected SessionAction::Message");
217        }
218    }
219}