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 bamboo_skills::{
10    ActiveWorkflow, WorkflowActivationStatus, WorkflowSelection, ACTIVE_WORKFLOW_METADATA_KEY,
11    ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY, WORKFLOW_ACTIVATION_EVENT_METADATA_KEY,
12    WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY, WORKFLOW_SELECTION_METADATA_KEY,
13};
14use sha2::{Digest, Sha256};
15use std::path::{Path, PathBuf};
16
17use super::errors::ChatError;
18use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
19use super::repository::SessionAccess;
20use super::types::ChatTurnInput;
21
22// ---- Metadata keys ----
23const BASE_SYSTEM_PROMPT_KEY: &str = "base_system_prompt";
24const SKILL_RUNTIME_LOADED_KEY: &str = "skill_runtime_loaded_skill_ids";
25const SKILL_RUNTIME_LAST_KEY: &str = "skill_runtime_last_loaded_skill_id";
26const COPILOT_CONCLUSION_KEY: &str = "copilot_conclusion_with_options_enhancement_enabled";
27const PROMPT_COMPOSER_VERSION_KEY: &str = "prompt_composer_version";
28const PROMPT_FINGERPRINT_KEY: &str = "prompt_fingerprint";
29const PROMPT_COMPONENT_FLAGS_KEY: &str = "prompt_component_flags";
30const PROMPT_COMPONENT_LENGTHS_KEY: &str = "prompt_component_lengths";
31
32const PROMPT_COMPOSER_VERSION: &str = "bamboo.prompt-composer.v2";
33pub const SESSION_START_SOURCE_METADATA_KEY: &str = "runtime.session_start_source";
34
35/// Prepare a chat turn: load/create session, resolve prompts, update metadata,
36/// append user message, persist.
37///
38/// Returns the prepared session ready for execution.
39///
40/// **Note**: Image handling and workspace sync (`ensure_session_workspace`)
41/// are NOT included here — those remain in the handler layer.
42pub async fn prepare_chat_turn(
43    repo: &dyn SessionAccess,
44    input: ChatTurnInput,
45    global_default_prompt: &str,
46    builtin_fallback_prompt: &str,
47) -> Result<Session, ChatError> {
48    prepare_chat_turn_inner(
49        repo,
50        input,
51        global_default_prompt,
52        builtin_fallback_prompt,
53        true,
54    )
55    .await
56}
57
58/// Prepare a chat turn without persisting it.
59///
60/// The server uses this variant so its authoritative Project/workspace
61/// resolver can run after the second session load and before any session or
62/// runtime workspace side effect. Callers must save the returned session after
63/// their external invariants pass.
64pub async fn prepare_chat_turn_unpersisted(
65    repo: &dyn SessionAccess,
66    input: ChatTurnInput,
67    global_default_prompt: &str,
68    builtin_fallback_prompt: &str,
69) -> Result<Session, ChatError> {
70    prepare_chat_turn_inner(
71        repo,
72        input,
73        global_default_prompt,
74        builtin_fallback_prompt,
75        false,
76    )
77    .await
78}
79
80async fn prepare_chat_turn_inner(
81    repo: &dyn SessionAccess,
82    input: ChatTurnInput,
83    global_default_prompt: &str,
84    builtin_fallback_prompt: &str,
85    persist: bool,
86) -> Result<Session, ChatError> {
87    let existing = repo.load_merged(&input.session_id).await?;
88    let mut session = prepare_chat_turn_from_authoritative_session(
89        existing,
90        input,
91        global_default_prompt,
92        builtin_fallback_prompt,
93    )?;
94    if persist {
95        repo.save_and_cache(&mut session).await?;
96    }
97    Ok(session)
98}
99
100/// Prepare a turn from a caller-supplied authoritative durable snapshot.
101///
102/// HTTP transaction paths acquire their per-session persistence lock, load
103/// directly from `LockedSessionStore::storage()`, and call this function so a
104/// stale cache can never win the Project membership re-check.
105pub fn prepare_chat_turn_from_authoritative_session(
106    existing: Option<Session>,
107    input: ChatTurnInput,
108    global_default_prompt: &str,
109    builtin_fallback_prompt: &str,
110) -> Result<Session, ChatError> {
111    let (mut session, session_start_source) = match existing {
112        Some(session) => (session, "resume"),
113        None => (
114            Session::new(input.session_id.clone(), input.model.clone()),
115            "startup",
116        ),
117    };
118    match crate::project_context::ProjectContextResolver::session_project_identity(&session) {
119        crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
120            return Err(ChatError::InvalidProjectIdentity { raw, message });
121        }
122        crate::project_context::SessionProjectIdentity::Assigned(actual)
123            if input.project_id.as_ref() != Some(&actual) =>
124        {
125            return Err(ChatError::ProjectIdentityConflict {
126                expected: input.project_id,
127                actual: Some(actual),
128            });
129        }
130        crate::project_context::SessionProjectIdentity::Unassigned
131            if session_start_source == "resume" && input.project_id.is_some() =>
132        {
133            return Err(ChatError::ProjectIdentityConflict {
134                expected: input.project_id,
135                actual: None,
136            });
137        }
138        crate::project_context::SessionProjectIdentity::Assigned(_)
139        | crate::project_context::SessionProjectIdentity::Unassigned => {}
140    }
141    session.metadata.insert(
142        SESSION_START_SOURCE_METADATA_KEY.to_string(),
143        session_start_source.to_string(),
144    );
145    if session_start_source == "startup" {
146        if let Some(project_id) = input.project_id.as_ref() {
147            session.set_project_id_meta(project_id.to_string());
148        }
149    }
150
151    // ---- Resolve base prompt ----
152    let base_prompt = resolve_base_prompt(
153        &mut session,
154        input.system_prompt.as_deref(),
155        global_default_prompt,
156        builtin_fallback_prompt,
157    );
158
159    // ---- Resolve enhance prompt ----
160    resolve_enhance_prompt(&mut session, input.enhance_prompt.as_deref());
161    let enhance_prompt = session.enhance_prompt();
162
163    // ---- Resolve copilot conclusion with options enhancement ----
164    resolve_copilot_conclusion_with_options_enhancement(
165        &mut session,
166        input.copilot_conclusion_with_options_enhancement_enabled,
167    );
168
169    // ---- Resolve workspace path (metadata only, no filesystem) ----
170    let workspace_path = resolve_workspace_path_with_default(
171        &mut session,
172        input.workspace_path.as_deref(),
173        input.default_workspace_path.as_deref(),
174        input.data_dir.as_deref(),
175    );
176
177    // ---- Resolve typed workflow selection / legacy skill IDs ----
178    resolve_workflow_selection(
179        &mut session,
180        input.workflow_selection.as_ref(),
181        input.selected_skill_ids.as_deref(),
182        &input.message,
183    )?;
184    if let Some(opted_in) = input.orchestration_opt_in {
185        session.metadata.insert(
186            WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY.to_string(),
187            opted_in.to_string(),
188        );
189    }
190
191    // ---- Build enhanced system prompt with profile ----
192    let (system_prompt, prompt_profile) = build_enhanced_system_prompt_with_profile(
193        &base_prompt,
194        enhance_prompt.as_deref(),
195        workspace_path.as_deref(),
196    );
197
198    session.metadata.insert(
199        PROMPT_COMPOSER_VERSION_KEY.to_string(),
200        prompt_profile.version.to_string(),
201    );
202    session.metadata.insert(
203        PROMPT_FINGERPRINT_KEY.to_string(),
204        prompt_profile.fingerprint.clone(),
205    );
206    session.metadata.insert(
207        PROMPT_COMPONENT_FLAGS_KEY.to_string(),
208        prompt_profile.component_flags_value(),
209    );
210    session.metadata.insert(
211        PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
212        prompt_profile.component_lengths_value(),
213    );
214
215    // ---- Upsert system prompt message ----
216    session
217        .messages
218        .retain(|message| !matches!(message.role, Role::System));
219    session.messages.insert(0, Message::system(system_prompt));
220    refresh_prompt_snapshot(&mut session);
221
222    // ---- Persist model/provider selection ----
223    let request_model_ref = derive_model_ref(
224        input.model_ref.as_ref(),
225        input.provider.as_deref(),
226        Some(input.model.as_str()),
227    );
228    if let Some(model_ref) = request_model_ref.as_ref() {
229        persist_model_ref(&mut session, model_ref);
230    } else {
231        persist_legacy_model_provider(
232            &mut session,
233            Some(input.model.as_str()),
234            input.provider.as_deref(),
235        );
236    }
237
238    Ok(session)
239}
240
241// ---- Internal helpers ----
242
243pub fn resolve_base_prompt(
244    session: &mut Session,
245    base_prompt_from_request: Option<&str>,
246    global_default_template: &str,
247    builtin_fallback: &str,
248) -> String {
249    let resolved = base_prompt_from_request
250        .map(ToString::to_string)
251        .or_else(|| {
252            session
253                .metadata
254                .get(BASE_SYSTEM_PROMPT_KEY)
255                .map(String::as_str)
256                .map(str::trim)
257                .filter(|value| !value.is_empty())
258                .map(ToString::to_string)
259        })
260        .or_else(|| {
261            session
262                .messages
263                .iter()
264                .find(|message| matches!(message.role, Role::System))
265                .map(|message| message.content.trim().to_string())
266                .filter(|value| !value.is_empty())
267        })
268        .unwrap_or_else(|| {
269            let trimmed = global_default_template.trim();
270            if trimmed.is_empty() {
271                builtin_fallback.to_string()
272            } else {
273                trimmed.to_string()
274            }
275        });
276
277    session
278        .metadata
279        .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
280    resolved
281}
282
283pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
284    if let Some(prompt) = enhance_prompt_from_request {
285        session.set_enhance_prompt(prompt);
286    } else {
287        session.clear_enhance_prompt();
288    }
289}
290
291pub fn resolve_copilot_conclusion_with_options_enhancement(
292    session: &mut Session,
293    enabled_from_request: Option<bool>,
294) {
295    if let Some(enabled) = enabled_from_request {
296        session
297            .metadata
298            .insert(COPILOT_CONCLUSION_KEY.to_string(), enabled.to_string());
299    } else {
300        session.metadata.remove(COPILOT_CONCLUSION_KEY);
301    }
302}
303
304pub fn resolve_workspace_path(
305    session: &mut Session,
306    workspace_path_from_request: Option<&str>,
307    data_dir: Option<&Path>,
308) -> Option<String> {
309    resolve_workspace_path_with_default(session, workspace_path_from_request, None, data_dir)
310}
311
312fn resolve_workspace_path_with_default(
313    session: &mut Session,
314    workspace_path_from_request: Option<&str>,
315    default_workspace_path: Option<&str>,
316    data_dir: Option<&Path>,
317) -> Option<String> {
318    if let Some(path) = workspace_path_from_request {
319        session.set_workspace_path_meta(path);
320    }
321
322    let resolved = workspace_path_from_request
323        .map(ToString::to_string)
324        .or_else(|| session.workspace_path_meta())
325        .or_else(|| default_workspace_path.map(ToString::to_string))
326        .or_else(|| resolve_default_workspace(data_dir));
327    if let Some(workspace) = resolved.as_ref() {
328        // Persist the effective post-lock choice, including a live-config or
329        // session-root fallback. Otherwise the subsequent Project resolver
330        // would see no metadata and independently pick a different global
331        // fallback.
332        session.set_workspace_path_meta(workspace.clone());
333    }
334    resolved
335}
336
337/// Resolve the configured default workspace (display string), preferring the
338/// server's live in-memory config.
339///
340/// If a workspace provider IS registered (the server, which owns the live
341/// `Arc<RwLock<Config>>`), it is AUTHORITATIVE: we use its result and never disk
342/// read — even when it resolves to `None` (no default work area configured).
343/// That closes the divergent disk read + global env-var-cache clobber for the
344/// whole server runtime (#38 / #131). Only when NO provider is registered
345/// (non-server contexts — SDK / CLI / unit tests) do we fall back to a direct
346/// `from_data_dir` read of `data_dir`.
347fn resolve_default_workspace(data_dir: Option<&Path>) -> Option<String> {
348    let configured = if bamboo_agent_core::workspace_state::has_default_workspace_provider() {
349        bamboo_agent_core::workspace_state::get_configured_default_workspace()
350    } else {
351        default_workspace_from_data_dir(data_dir)
352    };
353    configured.map(|path| path_to_display_string(&path))
354}
355
356/// Legacy non-server fallback: load `{data_dir}/config.json` from disk and read
357/// its default work area. Only used when no workspace provider is registered.
358fn default_workspace_from_data_dir(data_dir: Option<&Path>) -> Option<PathBuf> {
359    bamboo_llm::Config::from_data_dir(data_dir.map(Path::to_path_buf)).get_default_work_area_path()
360}
361
362pub fn resolve_selected_skill_ids(
363    session: &mut Session,
364    selected_skill_ids_from_request: Option<&[String]>,
365    message: &str,
366) {
367    if let Some(request_ids) = selected_skill_ids_from_request {
368        let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
369        persist_selected_skill_ids_metadata(session, normalized.as_deref());
370        return;
371    }
372
373    let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
374    if let Some(ids) = from_hint.as_ref() {
375        persist_selected_skill_ids_metadata(session, Some(ids));
376        return;
377    }
378
379    session.clear_selected_skill_ids();
380}
381
382pub fn resolve_workflow_selection(
383    session: &mut Session,
384    workflow_selection: Option<&WorkflowSelection>,
385    selected_skill_ids_from_request: Option<&[String]>,
386    message: &str,
387) -> Result<(), ChatError> {
388    if let Some(selection) = workflow_selection {
389        let id = selection.id.trim();
390        if id.is_empty() || selection.revision == 0 || !selection.args.is_object() {
391            return Err(ChatError::InvalidWorkflowSelection(
392                "id must be non-empty, revision must be positive, and args must be an object"
393                    .to_string(),
394            ));
395        }
396        let previous = session
397            .metadata
398            .get(WORKFLOW_SELECTION_METADATA_KEY)
399            .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok());
400        let selection_changed = previous.as_ref() != Some(selection);
401        session.metadata.insert(
402            WORKFLOW_SELECTION_METADATA_KEY.to_string(),
403            serde_json::to_string(selection).map_err(|_| {
404                ChatError::InvalidWorkflowSelection("selection cannot be serialized".to_string())
405            })?,
406        );
407        persist_selected_skill_ids_metadata(session, Some(&[id.to_string()]));
408        if selection_changed {
409            deactivate_active_workflow(session);
410            clear_skill_runtime_state(session);
411        }
412        return Ok(());
413    }
414
415    if selected_skill_ids_from_request.is_some() {
416        // Legacy explicit selection remains compatible, but can never override
417        // an authoritative typed selection in the same request.
418        session.metadata.remove(WORKFLOW_SELECTION_METADATA_KEY);
419        deactivate_active_workflow(session);
420        resolve_selected_skill_ids(session, selected_skill_ids_from_request, message);
421        clear_skill_runtime_state(session);
422        return Ok(());
423    }
424
425    if let Some(active) = session
426        .metadata
427        .get(ACTIVE_WORKFLOW_METADATA_KEY)
428        .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
429        .filter(|active| active.status == WorkflowActivationStatus::Active)
430    {
431        persist_selected_skill_ids_metadata(session, Some(&[active.id]));
432        return Ok(());
433    }
434
435    // Legacy natural-language hints are parsed only when there is no typed or
436    // durable active workflow. They remain compatibility input, never authority.
437    resolve_selected_skill_ids(session, None, message);
438    Ok(())
439}
440
441fn deactivate_active_workflow(session: &mut Session) {
442    if let Some(active) = session
443        .metadata
444        .get(ACTIVE_WORKFLOW_METADATA_KEY)
445        .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
446        .filter(|active| active.status == WorkflowActivationStatus::Active)
447    {
448        session.metadata.insert(
449            WORKFLOW_ACTIVATION_EVENT_METADATA_KEY.to_string(),
450            serde_json::json!({
451                "type": "workflow.deactivated",
452                "workflow_id": active.id,
453                "revision": active.revision,
454                "deactivated_at": chrono::Utc::now(),
455            })
456            .to_string(),
457        );
458    }
459    session.metadata.remove(ACTIVE_WORKFLOW_METADATA_KEY);
460    session
461        .metadata
462        .remove(ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY);
463}
464
465/// Clear skill runtime state markers from session metadata.
466pub fn clear_skill_runtime_state(session: &mut Session) {
467    session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
468    session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
469}
470
471fn persist_selected_skill_ids_metadata(
472    session: &mut Session,
473    selected_skill_ids: Option<&[String]>,
474) {
475    match selected_skill_ids {
476        Some(ids) if !ids.is_empty() => {
477            session.set_selected_skill_ids(ids.to_vec());
478        }
479        _ => {
480            session.clear_selected_skill_ids();
481        }
482    }
483}
484
485// ---- Goal command parsing ----
486
487/// Parsed result of a `/goal` command.
488#[derive(Debug, Clone, PartialEq, Eq)]
489pub enum GoalCommand {
490    /// `/goal status` or bare `/goal` — read-only status query.
491    Status,
492    /// `/goal off` or `/goal disable` or `/goal disabled`.
493    Off,
494    /// `/goal clear` or `/goal reset`.
495    Clear,
496    /// `/goal on` or `/goal enable` or `/goal enabled`.
497    On,
498    /// `/goal <prompt text>` — set the goal evaluation prompt and enable.
499    SetPrompt(String),
500}
501
502/// Attempt to parse a `/goal` command from the raw user message.
503/// Returns `None` if the message is not a `/goal` command.
504pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
505    let trimmed = message.trim();
506    if !trimmed.to_ascii_lowercase().starts_with("/goal") {
507        return None;
508    }
509    // Ensure "/goal" is followed by end-of-string or whitespace (not "/goalpost").
510    let rest = &trimmed[5..]; // skip "/goal"
511    if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
512        return None;
513    }
514
515    let arg = rest.trim().to_ascii_lowercase();
516
517    if arg.is_empty() {
518        return Some(GoalCommand::Status);
519    }
520
521    match arg.as_str() {
522        "status" => Some(GoalCommand::Status),
523        "off" | "disable" | "disabled" => Some(GoalCommand::Off),
524        "clear" | "reset" => Some(GoalCommand::Clear),
525        "on" | "enable" | "enabled" => Some(GoalCommand::On),
526        _ => {
527            // Everything else is treated as the goal prompt text.
528            // Use the original (non-lowercased) arg to preserve casing.
529            let prompt = trimmed
530                .strip_prefix("/goal")
531                .unwrap_or(trimmed)
532                .trim()
533                .to_string();
534            if prompt.is_empty() {
535                Some(GoalCommand::Status)
536            } else {
537                Some(GoalCommand::SetPrompt(prompt))
538            }
539        }
540    }
541}
542
543fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
544    const HINT_PREFIX: &str = "[User explicitly selected skill:";
545    let mut extracted = Vec::new();
546
547    for line in message.lines() {
548        let trimmed = line.trim();
549        if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
550            continue;
551        }
552
553        let Some(id_marker_index) = trimmed.rfind("(ID:") else {
554            continue;
555        };
556        let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
557        let Some(close_paren_index) = id_segment.find(')') else {
558            continue;
559        };
560        let id = id_segment[..close_paren_index].trim();
561        if !id.is_empty() {
562            extracted.push(id.to_string());
563        }
564    }
565
566    extracted
567}
568
569// ---- Prompt building ----
570
571#[derive(Debug, Clone, PartialEq, Eq)]
572struct PromptCompositionProfile {
573    version: &'static str,
574    fingerprint: String,
575    has_enhancement: bool,
576    has_workspace_context: bool,
577    has_env_context: bool,
578    base_len: usize,
579    enhancement_len: usize,
580    workspace_context_len: usize,
581    env_context_len: usize,
582    final_len: usize,
583}
584
585impl PromptCompositionProfile {
586    fn component_flags_value(&self) -> String {
587        format!(
588            "enhance={};workspace={};env={}",
589            self.has_enhancement as u8,
590            self.has_workspace_context as u8,
591            self.has_env_context as u8,
592        )
593    }
594
595    fn component_lengths_value(&self) -> String {
596        format!(
597            "base={};enhance={};workspace={};env={};final={}",
598            self.base_len,
599            self.enhancement_len,
600            self.workspace_context_len,
601            self.env_context_len,
602            self.final_len
603        )
604    }
605}
606
607fn build_prompt_fingerprint(
608    base_prompt: &str,
609    enhancement: Option<&str>,
610    workspace: Option<&str>,
611    env_context: Option<&str>,
612) -> String {
613    let mut hasher = Sha256::new();
614    hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
615    hasher.update([0u8]);
616    hasher.update(base_prompt.as_bytes());
617    hasher.update([0u8]);
618    hasher.update(enhancement.unwrap_or_default().as_bytes());
619    hasher.update([0u8]);
620    hasher.update(workspace.unwrap_or_default().as_bytes());
621    hasher.update([0u8]);
622    hasher.update(env_context.unwrap_or_default().as_bytes());
623    hex::encode(hasher.finalize())
624}
625
626fn build_enhanced_system_prompt_with_profile(
627    base_prompt: &str,
628    enhance_prompt: Option<&str>,
629    workspace_path: Option<&str>,
630) -> (String, PromptCompositionProfile) {
631    let mut merged_prompt = base_prompt.to_string();
632
633    let enhancement = enhance_prompt
634        .map(str::trim)
635        .filter(|enhancement| !enhancement.is_empty())
636        .map(ToString::to_string);
637    if let Some(enhancement) = enhancement.as_ref() {
638        merged_prompt.push_str("\n\n");
639        merged_prompt.push_str(enhancement.as_str());
640    }
641
642    let workspace_context = workspace_path
643        .map(str::trim)
644        .filter(|workspace_path| !workspace_path.is_empty())
645        .and_then(build_workspace_prompt_context);
646    if let Some(workspace_context) = workspace_context.as_ref() {
647        merged_prompt.push_str("\n\n");
648        merged_prompt.push_str(workspace_context.as_str());
649    }
650
651    let env_context = build_env_prompt_context();
652    if let Some(env_context) = env_context.as_ref() {
653        merged_prompt.push_str("\n\n");
654        merged_prompt.push_str(env_context.as_str());
655    }
656
657    let profile = PromptCompositionProfile {
658        version: PROMPT_COMPOSER_VERSION,
659        fingerprint: build_prompt_fingerprint(
660            base_prompt,
661            enhancement.as_deref(),
662            workspace_context.as_deref(),
663            env_context.as_deref(),
664        ),
665        has_enhancement: enhancement.is_some(),
666        has_workspace_context: workspace_context.is_some(),
667        has_env_context: env_context.is_some(),
668        base_len: base_prompt.len(),
669        enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
670        workspace_context_len: workspace_context.as_ref().map(|s| s.len()).unwrap_or(0),
671        env_context_len: env_context.as_ref().map(|s| s.len()).unwrap_or(0),
672        final_len: merged_prompt.len(),
673    };
674
675    (merged_prompt, profile)
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use crate::session_app::errors::{SessionLoadError, SessionSaveError};
682    use async_trait::async_trait;
683    use std::sync::atomic::{AtomicUsize, Ordering};
684    use std::sync::{Arc, Mutex};
685
686    struct InMemorySessionAccess;
687
688    struct ExistingSessionAccess(Session);
689
690    struct BarrierSessionAccess {
691        session: Mutex<Session>,
692        load_started: tokio::sync::Notify,
693        release_load: tokio::sync::Notify,
694        save_count: AtomicUsize,
695    }
696
697    #[async_trait]
698    impl SessionAccess for InMemorySessionAccess {
699        async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
700            Ok(None)
701        }
702
703        async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
704            Ok(Session::new(id, model))
705        }
706
707        async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
708            Ok(None)
709        }
710
711        async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
712            Ok(())
713        }
714
715        async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
716            Ok(())
717        }
718    }
719
720    #[async_trait]
721    impl SessionAccess for ExistingSessionAccess {
722        async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
723            Ok(Some(self.0.clone()))
724        }
725
726        async fn load_or_create(
727            &self,
728            _id: &str,
729            _model: &str,
730        ) -> Result<Session, SessionLoadError> {
731            panic!("existing chat session must not be recreated")
732        }
733
734        async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
735            Ok(Some(self.0.clone()))
736        }
737
738        async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
739            Ok(())
740        }
741
742        async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
743            Ok(())
744        }
745    }
746
747    #[async_trait]
748    impl SessionAccess for BarrierSessionAccess {
749        async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
750            Ok(Some(self.session.lock().expect("session lock").clone()))
751        }
752
753        async fn load_or_create(
754            &self,
755            _id: &str,
756            _model: &str,
757        ) -> Result<Session, SessionLoadError> {
758            panic!("barrier session already exists")
759        }
760
761        async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
762            self.load_started.notify_one();
763            self.release_load.notified().await;
764            Ok(Some(self.session.lock().expect("session lock").clone()))
765        }
766
767        async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
768            self.save_count.fetch_add(1, Ordering::SeqCst);
769            Ok(())
770        }
771
772        async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
773            self.save_count.fetch_add(1, Ordering::SeqCst);
774            Ok(())
775        }
776    }
777
778    fn chat_turn_input(enhance_prompt: Option<&str>) -> super::super::types::ChatTurnInput {
779        super::super::types::ChatTurnInput {
780            session_id: "session-enhance".to_string(),
781            project_id: None,
782            model: "gpt-5".to_string(),
783            model_ref: None,
784            provider: None,
785            message: "hello".to_string(),
786            system_prompt: Some("Base prompt".to_string()),
787            enhance_prompt: enhance_prompt.map(ToString::to_string),
788            workspace_path: None,
789            default_workspace_path: None,
790            selected_skill_ids: None,
791            workflow_selection: None,
792            orchestration_opt_in: None,
793            copilot_conclusion_with_options_enhancement_enabled: None,
794            data_dir: None,
795        }
796    }
797
798    fn system_message_content(session: &Session) -> String {
799        session
800            .messages
801            .iter()
802            .find(|message| matches!(message.role, Role::System))
803            .map(|message| message.content.clone())
804            .expect("session should have a system message")
805    }
806
807    fn active_workflow(id: &str, revision: u64) -> ActiveWorkflow {
808        ActiveWorkflow {
809            id: id.to_string(),
810            source: bamboo_skills::WorkflowSource::User,
811            revision,
812            kind: bamboo_skills::WorkflowKind::Instruction,
813            args: serde_json::json!({}),
814            invoked_by: bamboo_skills::WorkflowInvokedBy::User,
815            activated_at: chrono::Utc::now(),
816            status: WorkflowActivationStatus::Active,
817            diagnostic: None,
818            context_fingerprint: Some("fingerprint".to_string()),
819            dynamic_context: Vec::new(),
820        }
821    }
822
823    #[tokio::test]
824    async fn prepare_chat_turn_classifies_new_and_existing_sessions() {
825        let startup = prepare_chat_turn(
826            &InMemorySessionAccess,
827            chat_turn_input(None),
828            "global",
829            "builtin",
830        )
831        .await
832        .expect("new turn");
833        assert_eq!(
834            startup
835                .metadata
836                .get(SESSION_START_SOURCE_METADATA_KEY)
837                .map(String::as_str),
838            Some("startup")
839        );
840
841        let existing = prepare_chat_turn(
842            &ExistingSessionAccess(Session::new("session-enhance", "gpt-5")),
843            chat_turn_input(None),
844            "global",
845            "builtin",
846        )
847        .await
848        .expect("existing turn");
849        assert_eq!(
850            existing
851                .metadata
852                .get(SESSION_START_SOURCE_METADATA_KEY)
853                .map(String::as_str),
854            Some("resume")
855        );
856    }
857
858    #[tokio::test]
859    async fn prepare_chat_turn_rechecks_project_identity_after_preflight_race() {
860        let project_a =
861            bamboo_domain::ProjectId::parse("project-chat-a").expect("Project A identity");
862        let project_b =
863            bamboo_domain::ProjectId::parse("project-chat-b").expect("Project B identity");
864        let workspace_a = tempfile::tempdir().expect("workspace A");
865        let workspace_b = tempfile::tempdir().expect("workspace B");
866        let mut session = Session::new("chat-project-race", "gpt-5");
867        session.set_project_id_meta(project_a.to_string());
868        session.set_workspace_path_meta(workspace_a.path().to_string_lossy().into_owned());
869        let repo = Arc::new(BarrierSessionAccess {
870            session: Mutex::new(session),
871            load_started: tokio::sync::Notify::new(),
872            release_load: tokio::sync::Notify::new(),
873            save_count: AtomicUsize::new(0),
874        });
875        let mut input = chat_turn_input(None);
876        input.session_id = "chat-project-race".to_string();
877        input.project_id = Some(project_a.clone());
878        input.workspace_path = Some(workspace_a.path().to_string_lossy().into_owned());
879
880        let task_repo = repo.clone();
881        let task = tokio::spawn(async move {
882            prepare_chat_turn(task_repo.as_ref(), input, "global", "builtin").await
883        });
884        repo.load_started.notified().await;
885        {
886            let mut raced = repo.session.lock().expect("session lock");
887            raced.set_project_id_meta(project_b.to_string());
888            raced.set_workspace_path_meta(workspace_b.path().to_string_lossy().into_owned());
889        }
890        repo.release_load.notify_one();
891
892        let error = task
893            .await
894            .expect("chat task")
895            .expect_err("membership race must fail closed");
896        assert!(matches!(
897            error,
898            ChatError::ProjectIdentityConflict {
899                expected: Some(expected),
900                actual: Some(actual),
901            } if expected == project_a && actual == project_b
902        ));
903        assert_eq!(repo.save_count.load(Ordering::SeqCst), 0);
904        let persisted = repo.session.lock().expect("session lock");
905        assert_eq!(
906            persisted.workspace_path_meta().as_deref(),
907            Some(workspace_b.path().to_string_lossy().as_ref())
908        );
909        assert!(persisted.messages.is_empty());
910        assert!(!persisted
911            .metadata
912            .contains_key(SESSION_START_SOURCE_METADATA_KEY));
913    }
914
915    #[test]
916    fn typed_workflow_selection_is_authoritative_over_legacy_ids_and_hint() {
917        let mut session = Session::new("typed-selection", "model");
918        let selection = WorkflowSelection {
919            id: "review".to_string(),
920            source: bamboo_skills::WorkflowSource::User,
921            revision: 7,
922            args: serde_json::json!({"depth": "full"}),
923        };
924        resolve_workflow_selection(
925            &mut session,
926            Some(&selection),
927            Some(&["plan".to_string()]),
928            "use skill plan",
929        )
930        .expect("typed selection");
931        assert_eq!(
932            session.selected_skill_ids(),
933            Some(vec!["review".to_string()])
934        );
935        assert_eq!(
936            session
937                .metadata
938                .get(WORKFLOW_SELECTION_METADATA_KEY)
939                .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok()),
940            Some(selection)
941        );
942    }
943
944    #[test]
945    fn active_workflow_survives_turn_without_new_selection() {
946        let mut session = Session::new("active-selection", "model");
947        session.metadata.insert(
948            ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
949            serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
950        );
951        resolve_workflow_selection(&mut session, None, None, "use skill plan")
952            .expect("preserve active");
953        assert_eq!(
954            session.selected_skill_ids(),
955            Some(vec!["review".to_string()])
956        );
957        assert!(session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
958    }
959
960    #[test]
961    fn explicit_empty_legacy_selection_deactivates_active_workflow() {
962        let mut session = Session::new("deactivate-selection", "model");
963        session.metadata.insert(
964            ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
965            serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
966        );
967        resolve_workflow_selection(&mut session, None, Some(&[]), "plain message")
968            .expect("deactivate");
969        assert!(session.selected_skill_ids().is_none());
970        assert!(!session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
971        assert!(session
972            .metadata
973            .get(WORKFLOW_ACTIVATION_EVENT_METADATA_KEY)
974            .is_some_and(|event| event.contains("workflow.deactivated")));
975    }
976
977    // Regression: the request's enhance_prompt must land in the upserted system
978    // message, not just in session metadata (it was silently dropped once).
979    #[tokio::test]
980    async fn prepare_chat_turn_merges_enhance_prompt_into_system_message() {
981        let session = prepare_chat_turn(
982            &InMemorySessionAccess,
983            chat_turn_input(Some("Extra enhancement guidance")),
984            "",
985            "Builtin fallback",
986        )
987        .await
988        .expect("prepare_chat_turn should succeed");
989
990        let system_prompt = system_message_content(&session);
991        assert!(system_prompt.starts_with("Base prompt"));
992        assert!(system_prompt.contains("Extra enhancement guidance"));
993        assert_eq!(
994            session.enhance_prompt().as_deref(),
995            Some("Extra enhancement guidance")
996        );
997        assert!(session
998            .metadata
999            .get(PROMPT_COMPONENT_FLAGS_KEY)
1000            .is_some_and(|flags| flags.contains("enhance=1")));
1001    }
1002
1003    #[tokio::test]
1004    async fn prepare_chat_turn_without_enhance_prompt_keeps_base_only() {
1005        let session = prepare_chat_turn(
1006            &InMemorySessionAccess,
1007            chat_turn_input(None),
1008            "",
1009            "Builtin fallback",
1010        )
1011        .await
1012        .expect("prepare_chat_turn should succeed");
1013
1014        let system_prompt = system_message_content(&session);
1015        assert!(system_prompt.starts_with("Base prompt"));
1016        assert!(session.enhance_prompt().is_none());
1017        assert!(session
1018            .metadata
1019            .get(PROMPT_COMPONENT_FLAGS_KEY)
1020            .is_some_and(|flags| flags.contains("enhance=0")));
1021    }
1022
1023    // The non-server disk fallback (`default_workspace_from_data_dir`) tested
1024    // directly + deterministically — no global workspace-provider involved (the
1025    // server-side, provider-gated path can't be unit-tested due to the
1026    // first-wins OnceLock). #38 / #131.
1027
1028    #[test]
1029    fn default_workspace_from_data_dir_reads_configured_work_area() {
1030        let temp = tempfile::tempdir().expect("temp dir");
1031        let workspace = temp.path().join("default-workspace");
1032        std::fs::create_dir_all(&workspace).expect("workspace dir");
1033        std::fs::write(
1034            temp.path().join("config.json"),
1035            serde_json::json!({
1036                "default_work_area": { "path": workspace.to_string_lossy() }
1037            })
1038            .to_string(),
1039        )
1040        .expect("write config.json");
1041
1042        let resolved = default_workspace_from_data_dir(Some(temp.path())).expect("resolves");
1043        // get_default_work_area_path returns the non-canonical candidate, and temp
1044        // dirs live under a symlinked prefix on macOS (/var -> /private/var), so
1045        // canonicalize BOTH sides before comparing.
1046        assert_eq!(
1047            resolved.canonicalize().unwrap(),
1048            workspace.canonicalize().unwrap()
1049        );
1050    }
1051
1052    #[test]
1053    fn default_workspace_from_data_dir_is_none_without_config() {
1054        let temp = tempfile::tempdir().expect("temp dir");
1055        assert!(default_workspace_from_data_dir(Some(temp.path())).is_none());
1056    }
1057}