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