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    let (mut session, session_start_source) = match repo.load_merged(&input.session_id).await? {
49        Some(session) => (session, "resume"),
50        None => (
51            repo.load_or_create(&input.session_id, &input.model).await?,
52            "startup",
53        ),
54    };
55    session.metadata.insert(
56        SESSION_START_SOURCE_METADATA_KEY.to_string(),
57        session_start_source.to_string(),
58    );
59
60    // ---- Resolve base prompt ----
61    let base_prompt = resolve_base_prompt(
62        &mut session,
63        input.system_prompt.as_deref(),
64        global_default_prompt,
65        builtin_fallback_prompt,
66    );
67
68    // ---- Resolve enhance prompt ----
69    resolve_enhance_prompt(&mut session, input.enhance_prompt.as_deref());
70    let enhance_prompt = session.enhance_prompt();
71
72    // ---- Resolve copilot conclusion with options enhancement ----
73    resolve_copilot_conclusion_with_options_enhancement(
74        &mut session,
75        input.copilot_conclusion_with_options_enhancement_enabled,
76    );
77
78    // ---- Resolve workspace path (metadata only, no filesystem) ----
79    let workspace_path = resolve_workspace_path(
80        &mut session,
81        input.workspace_path.as_deref(),
82        input.data_dir.as_deref(),
83    );
84
85    // ---- Resolve typed workflow selection / legacy skill IDs ----
86    resolve_workflow_selection(
87        &mut session,
88        input.workflow_selection.as_ref(),
89        input.selected_skill_ids.as_deref(),
90        &input.message,
91    )?;
92    if let Some(opted_in) = input.orchestration_opt_in {
93        session.metadata.insert(
94            WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY.to_string(),
95            opted_in.to_string(),
96        );
97    }
98
99    // ---- Build enhanced system prompt with profile ----
100    let (system_prompt, prompt_profile) = build_enhanced_system_prompt_with_profile(
101        &base_prompt,
102        enhance_prompt.as_deref(),
103        workspace_path.as_deref(),
104    );
105
106    session.metadata.insert(
107        PROMPT_COMPOSER_VERSION_KEY.to_string(),
108        prompt_profile.version.to_string(),
109    );
110    session.metadata.insert(
111        PROMPT_FINGERPRINT_KEY.to_string(),
112        prompt_profile.fingerprint.clone(),
113    );
114    session.metadata.insert(
115        PROMPT_COMPONENT_FLAGS_KEY.to_string(),
116        prompt_profile.component_flags_value(),
117    );
118    session.metadata.insert(
119        PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
120        prompt_profile.component_lengths_value(),
121    );
122
123    // ---- Upsert system prompt message ----
124    session
125        .messages
126        .retain(|message| !matches!(message.role, Role::System));
127    session.messages.insert(0, Message::system(system_prompt));
128    refresh_prompt_snapshot(&mut session);
129
130    // ---- Persist model/provider selection ----
131    let request_model_ref = derive_model_ref(
132        input.model_ref.as_ref(),
133        input.provider.as_deref(),
134        Some(input.model.as_str()),
135    );
136    if let Some(model_ref) = request_model_ref.as_ref() {
137        persist_model_ref(&mut session, model_ref);
138    } else {
139        persist_legacy_model_provider(
140            &mut session,
141            Some(input.model.as_str()),
142            input.provider.as_deref(),
143        );
144    }
145
146    // ---- Save ----
147    repo.save_and_cache(&mut session).await?;
148
149    Ok(session)
150}
151
152// ---- Internal helpers ----
153
154pub fn resolve_base_prompt(
155    session: &mut Session,
156    base_prompt_from_request: Option<&str>,
157    global_default_template: &str,
158    builtin_fallback: &str,
159) -> String {
160    let resolved = base_prompt_from_request
161        .map(ToString::to_string)
162        .or_else(|| {
163            session
164                .metadata
165                .get(BASE_SYSTEM_PROMPT_KEY)
166                .map(String::as_str)
167                .map(str::trim)
168                .filter(|value| !value.is_empty())
169                .map(ToString::to_string)
170        })
171        .or_else(|| {
172            session
173                .messages
174                .iter()
175                .find(|message| matches!(message.role, Role::System))
176                .map(|message| message.content.trim().to_string())
177                .filter(|value| !value.is_empty())
178        })
179        .unwrap_or_else(|| {
180            let trimmed = global_default_template.trim();
181            if trimmed.is_empty() {
182                builtin_fallback.to_string()
183            } else {
184                trimmed.to_string()
185            }
186        });
187
188    session
189        .metadata
190        .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
191    resolved
192}
193
194pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
195    if let Some(prompt) = enhance_prompt_from_request {
196        session.set_enhance_prompt(prompt);
197    } else {
198        session.clear_enhance_prompt();
199    }
200}
201
202pub fn resolve_copilot_conclusion_with_options_enhancement(
203    session: &mut Session,
204    enabled_from_request: Option<bool>,
205) {
206    if let Some(enabled) = enabled_from_request {
207        session
208            .metadata
209            .insert(COPILOT_CONCLUSION_KEY.to_string(), enabled.to_string());
210    } else {
211        session.metadata.remove(COPILOT_CONCLUSION_KEY);
212    }
213}
214
215pub fn resolve_workspace_path(
216    session: &mut Session,
217    workspace_path_from_request: Option<&str>,
218    data_dir: Option<&Path>,
219) -> Option<String> {
220    if let Some(path) = workspace_path_from_request {
221        session.set_workspace_path_meta(path);
222    }
223
224    workspace_path_from_request
225        .map(ToString::to_string)
226        .or_else(|| session.workspace_path_meta())
227        .or_else(|| resolve_default_workspace(data_dir))
228}
229
230/// Resolve the configured default workspace (display string), preferring the
231/// server's live in-memory config.
232///
233/// If a workspace provider IS registered (the server, which owns the live
234/// `Arc<RwLock<Config>>`), it is AUTHORITATIVE: we use its result and never disk
235/// read — even when it resolves to `None` (no default work area configured).
236/// That closes the divergent disk read + global env-var-cache clobber for the
237/// whole server runtime (#38 / #131). Only when NO provider is registered
238/// (non-server contexts — SDK / CLI / unit tests) do we fall back to a direct
239/// `from_data_dir` read of `data_dir`.
240fn resolve_default_workspace(data_dir: Option<&Path>) -> Option<String> {
241    let configured = if bamboo_agent_core::workspace_state::has_default_workspace_provider() {
242        bamboo_agent_core::workspace_state::get_configured_default_workspace()
243    } else {
244        default_workspace_from_data_dir(data_dir)
245    };
246    configured.map(|path| path_to_display_string(&path))
247}
248
249/// Legacy non-server fallback: load `{data_dir}/config.json` from disk and read
250/// its default work area. Only used when no workspace provider is registered.
251fn default_workspace_from_data_dir(data_dir: Option<&Path>) -> Option<PathBuf> {
252    bamboo_llm::Config::from_data_dir(data_dir.map(Path::to_path_buf)).get_default_work_area_path()
253}
254
255pub fn resolve_selected_skill_ids(
256    session: &mut Session,
257    selected_skill_ids_from_request: Option<&[String]>,
258    message: &str,
259) {
260    if let Some(request_ids) = selected_skill_ids_from_request {
261        let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
262        persist_selected_skill_ids_metadata(session, normalized.as_deref());
263        return;
264    }
265
266    let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
267    if let Some(ids) = from_hint.as_ref() {
268        persist_selected_skill_ids_metadata(session, Some(ids));
269        return;
270    }
271
272    session.clear_selected_skill_ids();
273}
274
275pub fn resolve_workflow_selection(
276    session: &mut Session,
277    workflow_selection: Option<&WorkflowSelection>,
278    selected_skill_ids_from_request: Option<&[String]>,
279    message: &str,
280) -> Result<(), ChatError> {
281    if let Some(selection) = workflow_selection {
282        let id = selection.id.trim();
283        if id.is_empty() || selection.revision == 0 || !selection.args.is_object() {
284            return Err(ChatError::InvalidWorkflowSelection(
285                "id must be non-empty, revision must be positive, and args must be an object"
286                    .to_string(),
287            ));
288        }
289        let previous = session
290            .metadata
291            .get(WORKFLOW_SELECTION_METADATA_KEY)
292            .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok());
293        let selection_changed = previous.as_ref() != Some(selection);
294        session.metadata.insert(
295            WORKFLOW_SELECTION_METADATA_KEY.to_string(),
296            serde_json::to_string(selection).map_err(|_| {
297                ChatError::InvalidWorkflowSelection("selection cannot be serialized".to_string())
298            })?,
299        );
300        persist_selected_skill_ids_metadata(session, Some(&[id.to_string()]));
301        if selection_changed {
302            deactivate_active_workflow(session);
303            clear_skill_runtime_state(session);
304        }
305        return Ok(());
306    }
307
308    if selected_skill_ids_from_request.is_some() {
309        // Legacy explicit selection remains compatible, but can never override
310        // an authoritative typed selection in the same request.
311        session.metadata.remove(WORKFLOW_SELECTION_METADATA_KEY);
312        deactivate_active_workflow(session);
313        resolve_selected_skill_ids(session, selected_skill_ids_from_request, message);
314        clear_skill_runtime_state(session);
315        return Ok(());
316    }
317
318    if let Some(active) = session
319        .metadata
320        .get(ACTIVE_WORKFLOW_METADATA_KEY)
321        .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
322        .filter(|active| active.status == WorkflowActivationStatus::Active)
323    {
324        persist_selected_skill_ids_metadata(session, Some(&[active.id]));
325        return Ok(());
326    }
327
328    // Legacy natural-language hints are parsed only when there is no typed or
329    // durable active workflow. They remain compatibility input, never authority.
330    resolve_selected_skill_ids(session, None, message);
331    Ok(())
332}
333
334fn deactivate_active_workflow(session: &mut Session) {
335    if let Some(active) = session
336        .metadata
337        .get(ACTIVE_WORKFLOW_METADATA_KEY)
338        .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
339        .filter(|active| active.status == WorkflowActivationStatus::Active)
340    {
341        session.metadata.insert(
342            WORKFLOW_ACTIVATION_EVENT_METADATA_KEY.to_string(),
343            serde_json::json!({
344                "type": "workflow.deactivated",
345                "workflow_id": active.id,
346                "revision": active.revision,
347                "deactivated_at": chrono::Utc::now(),
348            })
349            .to_string(),
350        );
351    }
352    session.metadata.remove(ACTIVE_WORKFLOW_METADATA_KEY);
353    session
354        .metadata
355        .remove(ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY);
356}
357
358/// Clear skill runtime state markers from session metadata.
359pub fn clear_skill_runtime_state(session: &mut Session) {
360    session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
361    session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
362}
363
364fn persist_selected_skill_ids_metadata(
365    session: &mut Session,
366    selected_skill_ids: Option<&[String]>,
367) {
368    match selected_skill_ids {
369        Some(ids) if !ids.is_empty() => {
370            session.set_selected_skill_ids(ids.to_vec());
371        }
372        _ => {
373            session.clear_selected_skill_ids();
374        }
375    }
376}
377
378// ---- Goal command parsing ----
379
380/// Parsed result of a `/goal` command.
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum GoalCommand {
383    /// `/goal status` or bare `/goal` — read-only status query.
384    Status,
385    /// `/goal off` or `/goal disable` or `/goal disabled`.
386    Off,
387    /// `/goal clear` or `/goal reset`.
388    Clear,
389    /// `/goal on` or `/goal enable` or `/goal enabled`.
390    On,
391    /// `/goal <prompt text>` — set the goal evaluation prompt and enable.
392    SetPrompt(String),
393}
394
395/// Attempt to parse a `/goal` command from the raw user message.
396/// Returns `None` if the message is not a `/goal` command.
397pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
398    let trimmed = message.trim();
399    if !trimmed.to_ascii_lowercase().starts_with("/goal") {
400        return None;
401    }
402    // Ensure "/goal" is followed by end-of-string or whitespace (not "/goalpost").
403    let rest = &trimmed[5..]; // skip "/goal"
404    if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
405        return None;
406    }
407
408    let arg = rest.trim().to_ascii_lowercase();
409
410    if arg.is_empty() {
411        return Some(GoalCommand::Status);
412    }
413
414    match arg.as_str() {
415        "status" => Some(GoalCommand::Status),
416        "off" | "disable" | "disabled" => Some(GoalCommand::Off),
417        "clear" | "reset" => Some(GoalCommand::Clear),
418        "on" | "enable" | "enabled" => Some(GoalCommand::On),
419        _ => {
420            // Everything else is treated as the goal prompt text.
421            // Use the original (non-lowercased) arg to preserve casing.
422            let prompt = trimmed
423                .strip_prefix("/goal")
424                .unwrap_or(trimmed)
425                .trim()
426                .to_string();
427            if prompt.is_empty() {
428                Some(GoalCommand::Status)
429            } else {
430                Some(GoalCommand::SetPrompt(prompt))
431            }
432        }
433    }
434}
435
436fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
437    const HINT_PREFIX: &str = "[User explicitly selected skill:";
438    let mut extracted = Vec::new();
439
440    for line in message.lines() {
441        let trimmed = line.trim();
442        if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
443            continue;
444        }
445
446        let Some(id_marker_index) = trimmed.rfind("(ID:") else {
447            continue;
448        };
449        let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
450        let Some(close_paren_index) = id_segment.find(')') else {
451            continue;
452        };
453        let id = id_segment[..close_paren_index].trim();
454        if !id.is_empty() {
455            extracted.push(id.to_string());
456        }
457    }
458
459    extracted
460}
461
462// ---- Prompt building ----
463
464#[derive(Debug, Clone, PartialEq, Eq)]
465struct PromptCompositionProfile {
466    version: &'static str,
467    fingerprint: String,
468    has_enhancement: bool,
469    has_workspace_context: bool,
470    has_env_context: bool,
471    base_len: usize,
472    enhancement_len: usize,
473    workspace_context_len: usize,
474    env_context_len: usize,
475    final_len: usize,
476}
477
478impl PromptCompositionProfile {
479    fn component_flags_value(&self) -> String {
480        format!(
481            "enhance={};workspace={};env={}",
482            self.has_enhancement as u8,
483            self.has_workspace_context as u8,
484            self.has_env_context as u8,
485        )
486    }
487
488    fn component_lengths_value(&self) -> String {
489        format!(
490            "base={};enhance={};workspace={};env={};final={}",
491            self.base_len,
492            self.enhancement_len,
493            self.workspace_context_len,
494            self.env_context_len,
495            self.final_len
496        )
497    }
498}
499
500fn build_prompt_fingerprint(
501    base_prompt: &str,
502    enhancement: Option<&str>,
503    workspace: Option<&str>,
504    env_context: Option<&str>,
505) -> String {
506    let mut hasher = Sha256::new();
507    hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
508    hasher.update([0u8]);
509    hasher.update(base_prompt.as_bytes());
510    hasher.update([0u8]);
511    hasher.update(enhancement.unwrap_or_default().as_bytes());
512    hasher.update([0u8]);
513    hasher.update(workspace.unwrap_or_default().as_bytes());
514    hasher.update([0u8]);
515    hasher.update(env_context.unwrap_or_default().as_bytes());
516    hex::encode(hasher.finalize())
517}
518
519fn build_enhanced_system_prompt_with_profile(
520    base_prompt: &str,
521    enhance_prompt: Option<&str>,
522    workspace_path: Option<&str>,
523) -> (String, PromptCompositionProfile) {
524    let mut merged_prompt = base_prompt.to_string();
525
526    let enhancement = enhance_prompt
527        .map(str::trim)
528        .filter(|enhancement| !enhancement.is_empty())
529        .map(ToString::to_string);
530    if let Some(enhancement) = enhancement.as_ref() {
531        merged_prompt.push_str("\n\n");
532        merged_prompt.push_str(enhancement.as_str());
533    }
534
535    let workspace_context = workspace_path
536        .map(str::trim)
537        .filter(|workspace_path| !workspace_path.is_empty())
538        .and_then(build_workspace_prompt_context);
539    if let Some(workspace_context) = workspace_context.as_ref() {
540        merged_prompt.push_str("\n\n");
541        merged_prompt.push_str(workspace_context.as_str());
542    }
543
544    let env_context = build_env_prompt_context();
545    if let Some(env_context) = env_context.as_ref() {
546        merged_prompt.push_str("\n\n");
547        merged_prompt.push_str(env_context.as_str());
548    }
549
550    let profile = PromptCompositionProfile {
551        version: PROMPT_COMPOSER_VERSION,
552        fingerprint: build_prompt_fingerprint(
553            base_prompt,
554            enhancement.as_deref(),
555            workspace_context.as_deref(),
556            env_context.as_deref(),
557        ),
558        has_enhancement: enhancement.is_some(),
559        has_workspace_context: workspace_context.is_some(),
560        has_env_context: env_context.is_some(),
561        base_len: base_prompt.len(),
562        enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
563        workspace_context_len: workspace_context.as_ref().map(|s| s.len()).unwrap_or(0),
564        env_context_len: env_context.as_ref().map(|s| s.len()).unwrap_or(0),
565        final_len: merged_prompt.len(),
566    };
567
568    (merged_prompt, profile)
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use crate::session_app::errors::{SessionLoadError, SessionSaveError};
575    use async_trait::async_trait;
576
577    struct InMemorySessionAccess;
578
579    struct ExistingSessionAccess(Session);
580
581    #[async_trait]
582    impl SessionAccess for InMemorySessionAccess {
583        async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
584            Ok(None)
585        }
586
587        async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
588            Ok(Session::new(id, model))
589        }
590
591        async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
592            Ok(None)
593        }
594
595        async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
596            Ok(())
597        }
598
599        async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
600            Ok(())
601        }
602    }
603
604    #[async_trait]
605    impl SessionAccess for ExistingSessionAccess {
606        async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
607            Ok(Some(self.0.clone()))
608        }
609
610        async fn load_or_create(
611            &self,
612            _id: &str,
613            _model: &str,
614        ) -> Result<Session, SessionLoadError> {
615            panic!("existing chat session must not be recreated")
616        }
617
618        async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
619            Ok(Some(self.0.clone()))
620        }
621
622        async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
623            Ok(())
624        }
625
626        async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
627            Ok(())
628        }
629    }
630
631    fn chat_turn_input(enhance_prompt: Option<&str>) -> super::super::types::ChatTurnInput {
632        super::super::types::ChatTurnInput {
633            session_id: "session-enhance".to_string(),
634            model: "gpt-5".to_string(),
635            model_ref: None,
636            provider: None,
637            message: "hello".to_string(),
638            system_prompt: Some("Base prompt".to_string()),
639            enhance_prompt: enhance_prompt.map(ToString::to_string),
640            workspace_path: None,
641            selected_skill_ids: None,
642            workflow_selection: None,
643            orchestration_opt_in: None,
644            copilot_conclusion_with_options_enhancement_enabled: None,
645            data_dir: None,
646        }
647    }
648
649    fn system_message_content(session: &Session) -> String {
650        session
651            .messages
652            .iter()
653            .find(|message| matches!(message.role, Role::System))
654            .map(|message| message.content.clone())
655            .expect("session should have a system message")
656    }
657
658    fn active_workflow(id: &str, revision: u64) -> ActiveWorkflow {
659        ActiveWorkflow {
660            id: id.to_string(),
661            source: bamboo_skills::WorkflowSource::User,
662            revision,
663            kind: bamboo_skills::WorkflowKind::Instruction,
664            args: serde_json::json!({}),
665            invoked_by: bamboo_skills::WorkflowInvokedBy::User,
666            activated_at: chrono::Utc::now(),
667            status: WorkflowActivationStatus::Active,
668            diagnostic: None,
669            context_fingerprint: Some("fingerprint".to_string()),
670            dynamic_context: Vec::new(),
671        }
672    }
673
674    #[tokio::test]
675    async fn prepare_chat_turn_classifies_new_and_existing_sessions() {
676        let startup = prepare_chat_turn(
677            &InMemorySessionAccess,
678            chat_turn_input(None),
679            "global",
680            "builtin",
681        )
682        .await
683        .expect("new turn");
684        assert_eq!(
685            startup
686                .metadata
687                .get(SESSION_START_SOURCE_METADATA_KEY)
688                .map(String::as_str),
689            Some("startup")
690        );
691
692        let existing = prepare_chat_turn(
693            &ExistingSessionAccess(Session::new("session-enhance", "gpt-5")),
694            chat_turn_input(None),
695            "global",
696            "builtin",
697        )
698        .await
699        .expect("existing turn");
700        assert_eq!(
701            existing
702                .metadata
703                .get(SESSION_START_SOURCE_METADATA_KEY)
704                .map(String::as_str),
705            Some("resume")
706        );
707    }
708
709    #[test]
710    fn typed_workflow_selection_is_authoritative_over_legacy_ids_and_hint() {
711        let mut session = Session::new("typed-selection", "model");
712        let selection = WorkflowSelection {
713            id: "review".to_string(),
714            source: bamboo_skills::WorkflowSource::User,
715            revision: 7,
716            args: serde_json::json!({"depth": "full"}),
717        };
718        resolve_workflow_selection(
719            &mut session,
720            Some(&selection),
721            Some(&["plan".to_string()]),
722            "use skill plan",
723        )
724        .expect("typed selection");
725        assert_eq!(
726            session.selected_skill_ids(),
727            Some(vec!["review".to_string()])
728        );
729        assert_eq!(
730            session
731                .metadata
732                .get(WORKFLOW_SELECTION_METADATA_KEY)
733                .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok()),
734            Some(selection)
735        );
736    }
737
738    #[test]
739    fn active_workflow_survives_turn_without_new_selection() {
740        let mut session = Session::new("active-selection", "model");
741        session.metadata.insert(
742            ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
743            serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
744        );
745        resolve_workflow_selection(&mut session, None, None, "use skill plan")
746            .expect("preserve active");
747        assert_eq!(
748            session.selected_skill_ids(),
749            Some(vec!["review".to_string()])
750        );
751        assert!(session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
752    }
753
754    #[test]
755    fn explicit_empty_legacy_selection_deactivates_active_workflow() {
756        let mut session = Session::new("deactivate-selection", "model");
757        session.metadata.insert(
758            ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
759            serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
760        );
761        resolve_workflow_selection(&mut session, None, Some(&[]), "plain message")
762            .expect("deactivate");
763        assert!(session.selected_skill_ids().is_none());
764        assert!(!session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
765        assert!(session
766            .metadata
767            .get(WORKFLOW_ACTIVATION_EVENT_METADATA_KEY)
768            .is_some_and(|event| event.contains("workflow.deactivated")));
769    }
770
771    // Regression: the request's enhance_prompt must land in the upserted system
772    // message, not just in session metadata (it was silently dropped once).
773    #[tokio::test]
774    async fn prepare_chat_turn_merges_enhance_prompt_into_system_message() {
775        let session = prepare_chat_turn(
776            &InMemorySessionAccess,
777            chat_turn_input(Some("Extra enhancement guidance")),
778            "",
779            "Builtin fallback",
780        )
781        .await
782        .expect("prepare_chat_turn should succeed");
783
784        let system_prompt = system_message_content(&session);
785        assert!(system_prompt.starts_with("Base prompt"));
786        assert!(system_prompt.contains("Extra enhancement guidance"));
787        assert_eq!(
788            session.enhance_prompt().as_deref(),
789            Some("Extra enhancement guidance")
790        );
791        assert!(session
792            .metadata
793            .get(PROMPT_COMPONENT_FLAGS_KEY)
794            .is_some_and(|flags| flags.contains("enhance=1")));
795    }
796
797    #[tokio::test]
798    async fn prepare_chat_turn_without_enhance_prompt_keeps_base_only() {
799        let session = prepare_chat_turn(
800            &InMemorySessionAccess,
801            chat_turn_input(None),
802            "",
803            "Builtin fallback",
804        )
805        .await
806        .expect("prepare_chat_turn should succeed");
807
808        let system_prompt = system_message_content(&session);
809        assert!(system_prompt.starts_with("Base prompt"));
810        assert!(session.enhance_prompt().is_none());
811        assert!(session
812            .metadata
813            .get(PROMPT_COMPONENT_FLAGS_KEY)
814            .is_some_and(|flags| flags.contains("enhance=0")));
815    }
816
817    // The non-server disk fallback (`default_workspace_from_data_dir`) tested
818    // directly + deterministically — no global workspace-provider involved (the
819    // server-side, provider-gated path can't be unit-tested due to the
820    // first-wins OnceLock). #38 / #131.
821
822    #[test]
823    fn default_workspace_from_data_dir_reads_configured_work_area() {
824        let temp = tempfile::tempdir().expect("temp dir");
825        let workspace = temp.path().join("default-workspace");
826        std::fs::create_dir_all(&workspace).expect("workspace dir");
827        std::fs::write(
828            temp.path().join("config.json"),
829            serde_json::json!({
830                "default_work_area": { "path": workspace.to_string_lossy() }
831            })
832            .to_string(),
833        )
834        .expect("write config.json");
835
836        let resolved = default_workspace_from_data_dir(Some(temp.path())).expect("resolves");
837        // get_default_work_area_path returns the non-canonical candidate, and temp
838        // dirs live under a symlinked prefix on macOS (/var -> /private/var), so
839        // canonicalize BOTH sides before comparing.
840        assert_eq!(
841            resolved.canonicalize().unwrap(),
842            workspace.canonicalize().unwrap()
843        );
844    }
845
846    #[test]
847    fn default_workspace_from_data_dir_is_none_without_config() {
848        let temp = tempfile::tempdir().expect("temp dir");
849        assert!(default_workspace_from_data_dir(Some(temp.path())).is_none());
850    }
851}