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