Skip to main content

bamboo_engine/session_app/
chat.rs

1//! Chat use case: prepare a chat turn for execution.
2
3use crate::context::{build_env_prompt_context, build_workspace_prompt_context};
4use crate::runner::refresh_prompt_snapshot;
5use bamboo_agent_core::{Role, Session};
6use bamboo_config::paths::path_to_display_string;
7use bamboo_domain::Message;
8use bamboo_skills::selection::normalize_selected_skill_ids;
9use sha2::{Digest, Sha256};
10use std::path::{Path, PathBuf};
11
12use super::errors::ChatError;
13use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
14use super::repository::SessionAccess;
15use super::types::ChatTurnInput;
16
17// ---- Metadata keys ----
18const BASE_SYSTEM_PROMPT_KEY: &str = "base_system_prompt";
19const SKILL_RUNTIME_LOADED_KEY: &str = "skill_runtime_loaded_skill_ids";
20const SKILL_RUNTIME_LAST_KEY: &str = "skill_runtime_last_loaded_skill_id";
21const COPILOT_CONCLUSION_KEY: &str = "copilot_conclusion_with_options_enhancement_enabled";
22const PROMPT_COMPOSER_VERSION_KEY: &str = "prompt_composer_version";
23const PROMPT_FINGERPRINT_KEY: &str = "prompt_fingerprint";
24const PROMPT_COMPONENT_FLAGS_KEY: &str = "prompt_component_flags";
25const PROMPT_COMPONENT_LENGTHS_KEY: &str = "prompt_component_lengths";
26
27const PROMPT_COMPOSER_VERSION: &str = "bamboo.prompt-composer.v2";
28
29/// Prepare a chat turn: load/create session, resolve prompts, update metadata,
30/// append user message, persist.
31///
32/// Returns the prepared session ready for execution.
33///
34/// **Note**: Image handling and workspace sync (`ensure_session_workspace`)
35/// are NOT included here — those remain in the handler layer.
36pub async fn prepare_chat_turn(
37    repo: &dyn SessionAccess,
38    input: ChatTurnInput,
39    global_default_prompt: &str,
40    builtin_fallback_prompt: &str,
41) -> Result<Session, ChatError> {
42    let mut session = repo.load_or_create(&input.session_id, &input.model).await?;
43
44    // ---- Resolve base prompt ----
45    let base_prompt = resolve_base_prompt(
46        &mut session,
47        input.system_prompt.as_deref(),
48        global_default_prompt,
49        builtin_fallback_prompt,
50    );
51
52    // ---- Resolve enhance prompt ----
53    resolve_enhance_prompt(&mut session, input.enhance_prompt.as_deref());
54    let enhance_prompt = session.enhance_prompt();
55
56    // ---- Resolve copilot conclusion with options enhancement ----
57    resolve_copilot_conclusion_with_options_enhancement(
58        &mut session,
59        input.copilot_conclusion_with_options_enhancement_enabled,
60    );
61
62    // ---- Resolve workspace path (metadata only, no filesystem) ----
63    let workspace_path = resolve_workspace_path(
64        &mut session,
65        input.workspace_path.as_deref(),
66        input.data_dir.as_deref(),
67    );
68
69    // ---- Resolve selected skill IDs ----
70    resolve_selected_skill_ids(
71        &mut session,
72        input.selected_skill_ids.as_deref(),
73        &input.message,
74    );
75
76    // ---- Clear skill runtime state ----
77    session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
78    session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
79
80    // ---- Build enhanced system prompt with profile ----
81    let (system_prompt, prompt_profile) = build_enhanced_system_prompt_with_profile(
82        &base_prompt,
83        enhance_prompt.as_deref(),
84        workspace_path.as_deref(),
85    );
86
87    session.metadata.insert(
88        PROMPT_COMPOSER_VERSION_KEY.to_string(),
89        prompt_profile.version.to_string(),
90    );
91    session.metadata.insert(
92        PROMPT_FINGERPRINT_KEY.to_string(),
93        prompt_profile.fingerprint.clone(),
94    );
95    session.metadata.insert(
96        PROMPT_COMPONENT_FLAGS_KEY.to_string(),
97        prompt_profile.component_flags_value(),
98    );
99    session.metadata.insert(
100        PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
101        prompt_profile.component_lengths_value(),
102    );
103
104    // ---- Upsert system prompt message ----
105    session
106        .messages
107        .retain(|message| !matches!(message.role, Role::System));
108    session.messages.insert(0, Message::system(system_prompt));
109    refresh_prompt_snapshot(&mut session);
110
111    // ---- Persist model/provider selection ----
112    let request_model_ref = derive_model_ref(
113        input.model_ref.as_ref(),
114        input.provider.as_deref(),
115        Some(input.model.as_str()),
116    );
117    if let Some(model_ref) = request_model_ref.as_ref() {
118        persist_model_ref(&mut session, model_ref);
119    } else {
120        persist_legacy_model_provider(
121            &mut session,
122            Some(input.model.as_str()),
123            input.provider.as_deref(),
124        );
125    }
126
127    // ---- Save ----
128    repo.save_and_cache(&mut session).await?;
129
130    Ok(session)
131}
132
133// ---- Internal helpers ----
134
135pub fn resolve_base_prompt(
136    session: &mut Session,
137    base_prompt_from_request: Option<&str>,
138    global_default_template: &str,
139    builtin_fallback: &str,
140) -> String {
141    let resolved = base_prompt_from_request
142        .map(ToString::to_string)
143        .or_else(|| {
144            session
145                .metadata
146                .get(BASE_SYSTEM_PROMPT_KEY)
147                .map(String::as_str)
148                .map(str::trim)
149                .filter(|value| !value.is_empty())
150                .map(ToString::to_string)
151        })
152        .or_else(|| {
153            session
154                .messages
155                .iter()
156                .find(|message| matches!(message.role, Role::System))
157                .map(|message| message.content.trim().to_string())
158                .filter(|value| !value.is_empty())
159        })
160        .unwrap_or_else(|| {
161            let trimmed = global_default_template.trim();
162            if trimmed.is_empty() {
163                builtin_fallback.to_string()
164            } else {
165                trimmed.to_string()
166            }
167        });
168
169    session
170        .metadata
171        .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
172    resolved
173}
174
175pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
176    if let Some(prompt) = enhance_prompt_from_request {
177        session.set_enhance_prompt(prompt);
178    } else {
179        session.clear_enhance_prompt();
180    }
181}
182
183pub fn resolve_copilot_conclusion_with_options_enhancement(
184    session: &mut Session,
185    enabled_from_request: Option<bool>,
186) {
187    if let Some(enabled) = enabled_from_request {
188        session
189            .metadata
190            .insert(COPILOT_CONCLUSION_KEY.to_string(), enabled.to_string());
191    } else {
192        session.metadata.remove(COPILOT_CONCLUSION_KEY);
193    }
194}
195
196pub fn resolve_workspace_path(
197    session: &mut Session,
198    workspace_path_from_request: Option<&str>,
199    data_dir: Option<&Path>,
200) -> Option<String> {
201    if let Some(path) = workspace_path_from_request {
202        session.set_workspace_path_meta(path);
203    }
204
205    workspace_path_from_request
206        .map(ToString::to_string)
207        .or_else(|| session.workspace_path_meta())
208        .or_else(|| resolve_default_workspace(data_dir))
209}
210
211/// Resolve the configured default workspace (display string), preferring the
212/// server's live in-memory config.
213///
214/// If a workspace provider IS registered (the server, which owns the live
215/// `Arc<RwLock<Config>>`), it is AUTHORITATIVE: we use its result and never disk
216/// read — even when it resolves to `None` (no default work area configured).
217/// That closes the divergent disk read + global env-var-cache clobber for the
218/// whole server runtime (#38 / #131). Only when NO provider is registered
219/// (non-server contexts — SDK / CLI / unit tests) do we fall back to a direct
220/// `from_data_dir` read of `data_dir`.
221fn resolve_default_workspace(data_dir: Option<&Path>) -> Option<String> {
222    let configured = if bamboo_agent_core::workspace_state::has_default_workspace_provider() {
223        bamboo_agent_core::workspace_state::get_configured_default_workspace()
224    } else {
225        default_workspace_from_data_dir(data_dir)
226    };
227    configured.map(|path| path_to_display_string(&path))
228}
229
230/// Legacy non-server fallback: load `{data_dir}/config.json` from disk and read
231/// its default work area. Only used when no workspace provider is registered.
232fn default_workspace_from_data_dir(data_dir: Option<&Path>) -> Option<PathBuf> {
233    bamboo_llm::Config::from_data_dir(data_dir.map(Path::to_path_buf)).get_default_work_area_path()
234}
235
236pub fn resolve_selected_skill_ids(
237    session: &mut Session,
238    selected_skill_ids_from_request: Option<&[String]>,
239    message: &str,
240) {
241    if let Some(request_ids) = selected_skill_ids_from_request {
242        let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
243        persist_selected_skill_ids_metadata(session, normalized.as_deref());
244        return;
245    }
246
247    let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
248    if let Some(ids) = from_hint.as_ref() {
249        persist_selected_skill_ids_metadata(session, Some(ids));
250        return;
251    }
252
253    session.clear_selected_skill_ids();
254}
255
256/// Clear skill runtime state markers from session metadata.
257pub fn clear_skill_runtime_state(session: &mut Session) {
258    session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
259    session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
260}
261
262fn persist_selected_skill_ids_metadata(
263    session: &mut Session,
264    selected_skill_ids: Option<&[String]>,
265) {
266    match selected_skill_ids {
267        Some(ids) if !ids.is_empty() => {
268            session.set_selected_skill_ids(ids.to_vec());
269        }
270        _ => {
271            session.clear_selected_skill_ids();
272        }
273    }
274}
275
276// ---- Goal command parsing ----
277
278/// Parsed result of a `/goal` command.
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub enum GoalCommand {
281    /// `/goal status` or bare `/goal` — read-only status query.
282    Status,
283    /// `/goal off` or `/goal disable` or `/goal disabled`.
284    Off,
285    /// `/goal clear` or `/goal reset`.
286    Clear,
287    /// `/goal on` or `/goal enable` or `/goal enabled`.
288    On,
289    /// `/goal <prompt text>` — set the goal evaluation prompt and enable.
290    SetPrompt(String),
291}
292
293/// Attempt to parse a `/goal` command from the raw user message.
294/// Returns `None` if the message is not a `/goal` command.
295pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
296    let trimmed = message.trim();
297    if !trimmed.to_ascii_lowercase().starts_with("/goal") {
298        return None;
299    }
300    // Ensure "/goal" is followed by end-of-string or whitespace (not "/goalpost").
301    let rest = &trimmed[5..]; // skip "/goal"
302    if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
303        return None;
304    }
305
306    let arg = rest.trim().to_ascii_lowercase();
307
308    if arg.is_empty() {
309        return Some(GoalCommand::Status);
310    }
311
312    match arg.as_str() {
313        "status" => Some(GoalCommand::Status),
314        "off" | "disable" | "disabled" => Some(GoalCommand::Off),
315        "clear" | "reset" => Some(GoalCommand::Clear),
316        "on" | "enable" | "enabled" => Some(GoalCommand::On),
317        _ => {
318            // Everything else is treated as the goal prompt text.
319            // Use the original (non-lowercased) arg to preserve casing.
320            let prompt = trimmed
321                .strip_prefix("/goal")
322                .unwrap_or(trimmed)
323                .trim()
324                .to_string();
325            if prompt.is_empty() {
326                Some(GoalCommand::Status)
327            } else {
328                Some(GoalCommand::SetPrompt(prompt))
329            }
330        }
331    }
332}
333
334fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
335    const HINT_PREFIX: &str = "[User explicitly selected skill:";
336    let mut extracted = Vec::new();
337
338    for line in message.lines() {
339        let trimmed = line.trim();
340        if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
341            continue;
342        }
343
344        let Some(id_marker_index) = trimmed.rfind("(ID:") else {
345            continue;
346        };
347        let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
348        let Some(close_paren_index) = id_segment.find(')') else {
349            continue;
350        };
351        let id = id_segment[..close_paren_index].trim();
352        if !id.is_empty() {
353            extracted.push(id.to_string());
354        }
355    }
356
357    extracted
358}
359
360// ---- Prompt building ----
361
362#[derive(Debug, Clone, PartialEq, Eq)]
363struct PromptCompositionProfile {
364    version: &'static str,
365    fingerprint: String,
366    has_enhancement: bool,
367    has_workspace_context: bool,
368    has_env_context: bool,
369    base_len: usize,
370    enhancement_len: usize,
371    workspace_context_len: usize,
372    env_context_len: usize,
373    final_len: usize,
374}
375
376impl PromptCompositionProfile {
377    fn component_flags_value(&self) -> String {
378        format!(
379            "enhance={};workspace={};env={}",
380            self.has_enhancement as u8,
381            self.has_workspace_context as u8,
382            self.has_env_context as u8,
383        )
384    }
385
386    fn component_lengths_value(&self) -> String {
387        format!(
388            "base={};enhance={};workspace={};env={};final={}",
389            self.base_len,
390            self.enhancement_len,
391            self.workspace_context_len,
392            self.env_context_len,
393            self.final_len
394        )
395    }
396}
397
398fn build_prompt_fingerprint(
399    base_prompt: &str,
400    enhancement: Option<&str>,
401    workspace: Option<&str>,
402    env_context: Option<&str>,
403) -> String {
404    let mut hasher = Sha256::new();
405    hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
406    hasher.update([0u8]);
407    hasher.update(base_prompt.as_bytes());
408    hasher.update([0u8]);
409    hasher.update(enhancement.unwrap_or_default().as_bytes());
410    hasher.update([0u8]);
411    hasher.update(workspace.unwrap_or_default().as_bytes());
412    hasher.update([0u8]);
413    hasher.update(env_context.unwrap_or_default().as_bytes());
414    hex::encode(hasher.finalize())
415}
416
417fn build_enhanced_system_prompt_with_profile(
418    base_prompt: &str,
419    enhance_prompt: Option<&str>,
420    workspace_path: Option<&str>,
421) -> (String, PromptCompositionProfile) {
422    let mut merged_prompt = base_prompt.to_string();
423
424    let enhancement = enhance_prompt
425        .map(str::trim)
426        .filter(|enhancement| !enhancement.is_empty())
427        .map(ToString::to_string);
428    if let Some(enhancement) = enhancement.as_ref() {
429        merged_prompt.push_str("\n\n");
430        merged_prompt.push_str(enhancement.as_str());
431    }
432
433    let workspace_context = workspace_path
434        .map(str::trim)
435        .filter(|workspace_path| !workspace_path.is_empty())
436        .and_then(build_workspace_prompt_context);
437    if let Some(workspace_context) = workspace_context.as_ref() {
438        merged_prompt.push_str("\n\n");
439        merged_prompt.push_str(workspace_context.as_str());
440    }
441
442    let env_context = build_env_prompt_context();
443    if let Some(env_context) = env_context.as_ref() {
444        merged_prompt.push_str("\n\n");
445        merged_prompt.push_str(env_context.as_str());
446    }
447
448    let profile = PromptCompositionProfile {
449        version: PROMPT_COMPOSER_VERSION,
450        fingerprint: build_prompt_fingerprint(
451            base_prompt,
452            enhancement.as_deref(),
453            workspace_context.as_deref(),
454            env_context.as_deref(),
455        ),
456        has_enhancement: enhancement.is_some(),
457        has_workspace_context: workspace_context.is_some(),
458        has_env_context: env_context.is_some(),
459        base_len: base_prompt.len(),
460        enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
461        workspace_context_len: workspace_context.as_ref().map(|s| s.len()).unwrap_or(0),
462        env_context_len: env_context.as_ref().map(|s| s.len()).unwrap_or(0),
463        final_len: merged_prompt.len(),
464    };
465
466    (merged_prompt, profile)
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::session_app::errors::{SessionLoadError, SessionSaveError};
473    use async_trait::async_trait;
474
475    struct InMemorySessionAccess;
476
477    #[async_trait]
478    impl SessionAccess for InMemorySessionAccess {
479        async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
480            Ok(None)
481        }
482
483        async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
484            Ok(Session::new(id, model))
485        }
486
487        async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
488            Ok(None)
489        }
490
491        async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
492            Ok(())
493        }
494
495        async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
496            Ok(())
497        }
498    }
499
500    fn chat_turn_input(enhance_prompt: Option<&str>) -> super::super::types::ChatTurnInput {
501        super::super::types::ChatTurnInput {
502            session_id: "session-enhance".to_string(),
503            model: "gpt-5".to_string(),
504            model_ref: None,
505            provider: None,
506            message: "hello".to_string(),
507            system_prompt: Some("Base prompt".to_string()),
508            enhance_prompt: enhance_prompt.map(ToString::to_string),
509            workspace_path: None,
510            selected_skill_ids: None,
511            copilot_conclusion_with_options_enhancement_enabled: None,
512            data_dir: None,
513        }
514    }
515
516    fn system_message_content(session: &Session) -> String {
517        session
518            .messages
519            .iter()
520            .find(|message| matches!(message.role, Role::System))
521            .map(|message| message.content.clone())
522            .expect("session should have a system message")
523    }
524
525    // Regression: the request's enhance_prompt must land in the upserted system
526    // message, not just in session metadata (it was silently dropped once).
527    #[tokio::test]
528    async fn prepare_chat_turn_merges_enhance_prompt_into_system_message() {
529        let session = prepare_chat_turn(
530            &InMemorySessionAccess,
531            chat_turn_input(Some("Extra enhancement guidance")),
532            "",
533            "Builtin fallback",
534        )
535        .await
536        .expect("prepare_chat_turn should succeed");
537
538        let system_prompt = system_message_content(&session);
539        assert!(system_prompt.starts_with("Base prompt"));
540        assert!(system_prompt.contains("Extra enhancement guidance"));
541        assert_eq!(
542            session.enhance_prompt().as_deref(),
543            Some("Extra enhancement guidance")
544        );
545        assert!(session
546            .metadata
547            .get(PROMPT_COMPONENT_FLAGS_KEY)
548            .is_some_and(|flags| flags.contains("enhance=1")));
549    }
550
551    #[tokio::test]
552    async fn prepare_chat_turn_without_enhance_prompt_keeps_base_only() {
553        let session = prepare_chat_turn(
554            &InMemorySessionAccess,
555            chat_turn_input(None),
556            "",
557            "Builtin fallback",
558        )
559        .await
560        .expect("prepare_chat_turn should succeed");
561
562        let system_prompt = system_message_content(&session);
563        assert!(system_prompt.starts_with("Base prompt"));
564        assert!(session.enhance_prompt().is_none());
565        assert!(session
566            .metadata
567            .get(PROMPT_COMPONENT_FLAGS_KEY)
568            .is_some_and(|flags| flags.contains("enhance=0")));
569    }
570
571    // The non-server disk fallback (`default_workspace_from_data_dir`) tested
572    // directly + deterministically — no global workspace-provider involved (the
573    // server-side, provider-gated path can't be unit-tested due to the
574    // first-wins OnceLock). #38 / #131.
575
576    #[test]
577    fn default_workspace_from_data_dir_reads_configured_work_area() {
578        let temp = tempfile::tempdir().expect("temp dir");
579        let workspace = temp.path().join("default-workspace");
580        std::fs::create_dir_all(&workspace).expect("workspace dir");
581        std::fs::write(
582            temp.path().join("config.json"),
583            serde_json::json!({
584                "default_work_area": { "path": workspace.to_string_lossy() }
585            })
586            .to_string(),
587        )
588        .expect("write config.json");
589
590        let resolved = default_workspace_from_data_dir(Some(temp.path())).expect("resolves");
591        // get_default_work_area_path returns the non-canonical candidate, and temp
592        // dirs live under a symlinked prefix on macOS (/var -> /private/var), so
593        // canonicalize BOTH sides before comparing.
594        assert_eq!(
595            resolved.canonicalize().unwrap(),
596            workspace.canonicalize().unwrap()
597        );
598    }
599
600    #[test]
601    fn default_workspace_from_data_dir_is_none_without_config() {
602        let temp = tempfile::tempdir().expect("temp dir");
603        assert!(default_workspace_from_data_dir(Some(temp.path())).is_none());
604    }
605}