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