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
55    // ---- Resolve copilot conclusion with options enhancement ----
56    resolve_copilot_conclusion_with_options_enhancement(
57        &mut session,
58        input.copilot_conclusion_with_options_enhancement_enabled,
59    );
60
61    // ---- Resolve workspace path (metadata only, no filesystem) ----
62    let workspace_path = resolve_workspace_path(
63        &mut session,
64        input.workspace_path.as_deref(),
65        input.data_dir.as_deref(),
66    );
67
68    // ---- Resolve selected skill IDs ----
69    resolve_selected_skill_ids(
70        &mut session,
71        input.selected_skill_ids.as_deref(),
72        &input.message,
73    );
74
75    // ---- Clear skill runtime state ----
76    session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
77    session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
78
79    // ---- Build enhanced system prompt with profile ----
80    let (system_prompt, prompt_profile) =
81        build_enhanced_system_prompt_with_profile(&base_prompt, None, workspace_path.as_deref());
82
83    session.metadata.insert(
84        PROMPT_COMPOSER_VERSION_KEY.to_string(),
85        prompt_profile.version.to_string(),
86    );
87    session.metadata.insert(
88        PROMPT_FINGERPRINT_KEY.to_string(),
89        prompt_profile.fingerprint.clone(),
90    );
91    session.metadata.insert(
92        PROMPT_COMPONENT_FLAGS_KEY.to_string(),
93        prompt_profile.component_flags_value(),
94    );
95    session.metadata.insert(
96        PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
97        prompt_profile.component_lengths_value(),
98    );
99
100    // ---- Upsert system prompt message ----
101    session
102        .messages
103        .retain(|message| !matches!(message.role, Role::System));
104    session.messages.insert(0, Message::system(system_prompt));
105    refresh_prompt_snapshot(&mut session);
106
107    // ---- Persist model/provider selection ----
108    let request_model_ref = derive_model_ref(
109        input.model_ref.as_ref(),
110        input.provider.as_deref(),
111        Some(input.model.as_str()),
112    );
113    if let Some(model_ref) = request_model_ref.as_ref() {
114        persist_model_ref(&mut session, model_ref);
115    } else {
116        persist_legacy_model_provider(
117            &mut session,
118            Some(input.model.as_str()),
119            input.provider.as_deref(),
120        );
121    }
122
123    // ---- Save ----
124    repo.save_and_cache(&mut session).await?;
125
126    Ok(session)
127}
128
129// ---- Internal helpers ----
130
131pub fn resolve_base_prompt(
132    session: &mut Session,
133    base_prompt_from_request: Option<&str>,
134    global_default_template: &str,
135    builtin_fallback: &str,
136) -> String {
137    let resolved = base_prompt_from_request
138        .map(ToString::to_string)
139        .or_else(|| {
140            session
141                .metadata
142                .get(BASE_SYSTEM_PROMPT_KEY)
143                .map(String::as_str)
144                .map(str::trim)
145                .filter(|value| !value.is_empty())
146                .map(ToString::to_string)
147        })
148        .or_else(|| {
149            session
150                .messages
151                .iter()
152                .find(|message| matches!(message.role, Role::System))
153                .map(|message| message.content.trim().to_string())
154                .filter(|value| !value.is_empty())
155        })
156        .unwrap_or_else(|| {
157            let trimmed = global_default_template.trim();
158            if trimmed.is_empty() {
159                builtin_fallback.to_string()
160            } else {
161                trimmed.to_string()
162            }
163        });
164
165    session
166        .metadata
167        .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
168    resolved
169}
170
171pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
172    if let Some(prompt) = enhance_prompt_from_request {
173        session.set_enhance_prompt(prompt);
174    } else {
175        session.clear_enhance_prompt();
176    }
177}
178
179pub fn resolve_copilot_conclusion_with_options_enhancement(
180    session: &mut Session,
181    enabled_from_request: Option<bool>,
182) {
183    if let Some(enabled) = enabled_from_request {
184        session
185            .metadata
186            .insert(COPILOT_CONCLUSION_KEY.to_string(), enabled.to_string());
187    } else {
188        session.metadata.remove(COPILOT_CONCLUSION_KEY);
189    }
190}
191
192pub fn resolve_workspace_path(
193    session: &mut Session,
194    workspace_path_from_request: Option<&str>,
195    data_dir: Option<&Path>,
196) -> Option<String> {
197    if let Some(path) = workspace_path_from_request {
198        session.set_workspace_path_meta(path);
199    }
200
201    workspace_path_from_request
202        .map(ToString::to_string)
203        .or_else(|| session.workspace_path_meta())
204        .or_else(|| resolve_default_workspace(data_dir))
205}
206
207/// Resolve the configured default workspace (display string), preferring the
208/// server's live in-memory config.
209///
210/// If a workspace provider IS registered (the server, which owns the live
211/// `Arc<RwLock<Config>>`), it is AUTHORITATIVE: we use its result and never disk
212/// read — even when it resolves to `None` (no default work area configured).
213/// That closes the divergent disk read + global env-var-cache clobber for the
214/// whole server runtime (#38 / #131). Only when NO provider is registered
215/// (non-server contexts — SDK / CLI / unit tests) do we fall back to a direct
216/// `from_data_dir` read of `data_dir`.
217fn resolve_default_workspace(data_dir: Option<&Path>) -> Option<String> {
218    let configured = if bamboo_agent_core::workspace_state::has_default_workspace_provider() {
219        bamboo_agent_core::workspace_state::get_configured_default_workspace()
220    } else {
221        default_workspace_from_data_dir(data_dir)
222    };
223    configured.map(|path| path_to_display_string(&path))
224}
225
226/// Legacy non-server fallback: load `{data_dir}/config.json` from disk and read
227/// its default work area. Only used when no workspace provider is registered.
228fn default_workspace_from_data_dir(data_dir: Option<&Path>) -> Option<PathBuf> {
229    bamboo_llm::Config::from_data_dir(data_dir.map(Path::to_path_buf)).get_default_work_area_path()
230}
231
232pub fn resolve_selected_skill_ids(
233    session: &mut Session,
234    selected_skill_ids_from_request: Option<&[String]>,
235    message: &str,
236) {
237    if let Some(request_ids) = selected_skill_ids_from_request {
238        let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
239        persist_selected_skill_ids_metadata(session, normalized.as_deref());
240        return;
241    }
242
243    let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
244    if let Some(ids) = from_hint.as_ref() {
245        persist_selected_skill_ids_metadata(session, Some(ids));
246        return;
247    }
248
249    session.clear_selected_skill_ids();
250}
251
252/// Clear skill runtime state markers from session metadata.
253pub fn clear_skill_runtime_state(session: &mut Session) {
254    session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
255    session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
256}
257
258fn persist_selected_skill_ids_metadata(
259    session: &mut Session,
260    selected_skill_ids: Option<&[String]>,
261) {
262    match selected_skill_ids {
263        Some(ids) if !ids.is_empty() => {
264            session.set_selected_skill_ids(ids.to_vec());
265        }
266        _ => {
267            session.clear_selected_skill_ids();
268        }
269    }
270}
271
272// ---- Goal command parsing ----
273
274/// Parsed result of a `/goal` command.
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum GoalCommand {
277    /// `/goal status` or bare `/goal` — read-only status query.
278    Status,
279    /// `/goal off` or `/goal disable` or `/goal disabled`.
280    Off,
281    /// `/goal clear` or `/goal reset`.
282    Clear,
283    /// `/goal on` or `/goal enable` or `/goal enabled`.
284    On,
285    /// `/goal <prompt text>` — set the goal evaluation prompt and enable.
286    SetPrompt(String),
287}
288
289/// Attempt to parse a `/goal` command from the raw user message.
290/// Returns `None` if the message is not a `/goal` command.
291pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
292    let trimmed = message.trim();
293    if !trimmed.to_ascii_lowercase().starts_with("/goal") {
294        return None;
295    }
296    // Ensure "/goal" is followed by end-of-string or whitespace (not "/goalpost").
297    let rest = &trimmed[5..]; // skip "/goal"
298    if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
299        return None;
300    }
301
302    let arg = rest.trim().to_ascii_lowercase();
303
304    if arg.is_empty() {
305        return Some(GoalCommand::Status);
306    }
307
308    match arg.as_str() {
309        "status" => Some(GoalCommand::Status),
310        "off" | "disable" | "disabled" => Some(GoalCommand::Off),
311        "clear" | "reset" => Some(GoalCommand::Clear),
312        "on" | "enable" | "enabled" => Some(GoalCommand::On),
313        _ => {
314            // Everything else is treated as the goal prompt text.
315            // Use the original (non-lowercased) arg to preserve casing.
316            let prompt = trimmed
317                .strip_prefix("/goal")
318                .unwrap_or(trimmed)
319                .trim()
320                .to_string();
321            if prompt.is_empty() {
322                Some(GoalCommand::Status)
323            } else {
324                Some(GoalCommand::SetPrompt(prompt))
325            }
326        }
327    }
328}
329
330fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
331    const HINT_PREFIX: &str = "[User explicitly selected skill:";
332    let mut extracted = Vec::new();
333
334    for line in message.lines() {
335        let trimmed = line.trim();
336        if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
337            continue;
338        }
339
340        let Some(id_marker_index) = trimmed.rfind("(ID:") else {
341            continue;
342        };
343        let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
344        let Some(close_paren_index) = id_segment.find(')') else {
345            continue;
346        };
347        let id = id_segment[..close_paren_index].trim();
348        if !id.is_empty() {
349            extracted.push(id.to_string());
350        }
351    }
352
353    extracted
354}
355
356// ---- Prompt building ----
357
358#[derive(Debug, Clone, PartialEq, Eq)]
359struct PromptCompositionProfile {
360    version: &'static str,
361    fingerprint: String,
362    has_enhancement: bool,
363    has_workspace_context: bool,
364    has_env_context: bool,
365    base_len: usize,
366    enhancement_len: usize,
367    workspace_context_len: usize,
368    env_context_len: usize,
369    final_len: usize,
370}
371
372impl PromptCompositionProfile {
373    fn component_flags_value(&self) -> String {
374        format!(
375            "enhance={};workspace={};env={}",
376            self.has_enhancement as u8,
377            self.has_workspace_context as u8,
378            self.has_env_context as u8,
379        )
380    }
381
382    fn component_lengths_value(&self) -> String {
383        format!(
384            "base={};enhance={};workspace={};env={};final={}",
385            self.base_len,
386            self.enhancement_len,
387            self.workspace_context_len,
388            self.env_context_len,
389            self.final_len
390        )
391    }
392}
393
394fn build_prompt_fingerprint(
395    base_prompt: &str,
396    enhancement: Option<&str>,
397    workspace: Option<&str>,
398    env_context: Option<&str>,
399) -> String {
400    let mut hasher = Sha256::new();
401    hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
402    hasher.update([0u8]);
403    hasher.update(base_prompt.as_bytes());
404    hasher.update([0u8]);
405    hasher.update(enhancement.unwrap_or_default().as_bytes());
406    hasher.update([0u8]);
407    hasher.update(workspace.unwrap_or_default().as_bytes());
408    hasher.update([0u8]);
409    hasher.update(env_context.unwrap_or_default().as_bytes());
410    format!("{:x}", hasher.finalize())
411}
412
413fn build_enhanced_system_prompt_with_profile(
414    base_prompt: &str,
415    enhance_prompt: Option<&str>,
416    workspace_path: Option<&str>,
417) -> (String, PromptCompositionProfile) {
418    let mut merged_prompt = base_prompt.to_string();
419
420    let enhancement = enhance_prompt
421        .map(str::trim)
422        .filter(|enhancement| !enhancement.is_empty())
423        .map(ToString::to_string);
424    if let Some(enhancement) = enhancement.as_ref() {
425        merged_prompt.push_str("\n\n");
426        merged_prompt.push_str(enhancement.as_str());
427    }
428
429    let workspace_context = workspace_path
430        .map(str::trim)
431        .filter(|workspace_path| !workspace_path.is_empty())
432        .and_then(build_workspace_prompt_context);
433    if let Some(workspace_context) = workspace_context.as_ref() {
434        merged_prompt.push_str("\n\n");
435        merged_prompt.push_str(workspace_context.as_str());
436    }
437
438    let env_context = build_env_prompt_context();
439    if let Some(env_context) = env_context.as_ref() {
440        merged_prompt.push_str("\n\n");
441        merged_prompt.push_str(env_context.as_str());
442    }
443
444    let profile = PromptCompositionProfile {
445        version: PROMPT_COMPOSER_VERSION,
446        fingerprint: build_prompt_fingerprint(
447            base_prompt,
448            enhancement.as_deref(),
449            workspace_context.as_deref(),
450            env_context.as_deref(),
451        ),
452        has_enhancement: enhancement.is_some(),
453        has_workspace_context: workspace_context.is_some(),
454        has_env_context: env_context.is_some(),
455        base_len: base_prompt.len(),
456        enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
457        workspace_context_len: workspace_context.as_ref().map(|s| s.len()).unwrap_or(0),
458        env_context_len: env_context.as_ref().map(|s| s.len()).unwrap_or(0),
459        final_len: merged_prompt.len(),
460    };
461
462    (merged_prompt, profile)
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    // The non-server disk fallback (`default_workspace_from_data_dir`) tested
470    // directly + deterministically — no global workspace-provider involved (the
471    // server-side, provider-gated path can't be unit-tested due to the
472    // first-wins OnceLock). #38 / #131.
473
474    #[test]
475    fn default_workspace_from_data_dir_reads_configured_work_area() {
476        let temp = tempfile::tempdir().expect("temp dir");
477        let workspace = temp.path().join("default-workspace");
478        std::fs::create_dir_all(&workspace).expect("workspace dir");
479        std::fs::write(
480            temp.path().join("config.json"),
481            serde_json::json!({
482                "default_work_area": { "path": workspace.to_string_lossy() }
483            })
484            .to_string(),
485        )
486        .expect("write config.json");
487
488        let resolved = default_workspace_from_data_dir(Some(temp.path())).expect("resolves");
489        // get_default_work_area_path returns the non-canonical candidate, and temp
490        // dirs live under a symlinked prefix on macOS (/var -> /private/var), so
491        // canonicalize BOTH sides before comparing.
492        assert_eq!(
493            resolved.canonicalize().unwrap(),
494            workspace.canonicalize().unwrap()
495        );
496    }
497
498    #[test]
499    fn default_workspace_from_data_dir_is_none_without_config() {
500        let temp = tempfile::tempdir().expect("temp dir");
501        assert!(default_workspace_from_data_dir(Some(temp.path())).is_none());
502    }
503}