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