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_CATALOG_DIAGNOSTIC_METADATA_KEY, WORKFLOW_CONTEXT_CACHE_METADATA_KEY,
13    WORKFLOW_LAST_DYNAMIC_CONTEXT_METADATA_KEY, WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY,
14    WORKFLOW_SELECTION_METADATA_KEY,
15};
16use sha2::{Digest, Sha256};
17use std::path::{Path, PathBuf};
18
19use super::errors::ChatError;
20use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
21use super::repository::SessionAccess;
22use super::types::{ChatTurnInput, ChatWorkspaceFallbackPolicy};
23
24// ---- Metadata keys ----
25const BASE_SYSTEM_PROMPT_KEY: &str = "base_system_prompt";
26const SKILL_RUNTIME_LOADED_KEY: &str = "skill_runtime_loaded_skill_ids";
27const SKILL_RUNTIME_LAST_KEY: &str = "skill_runtime_last_loaded_skill_id";
28const PROMPT_COMPOSER_VERSION_KEY: &str = "prompt_composer_version";
29const PROMPT_FINGERPRINT_KEY: &str = "prompt_fingerprint";
30const PROMPT_COMPONENT_FLAGS_KEY: &str = "prompt_component_flags";
31const PROMPT_COMPONENT_LENGTHS_KEY: &str = "prompt_component_lengths";
32
33const PROMPT_COMPOSER_VERSION: &str = "bamboo.prompt-composer.v3";
34pub const SESSION_START_SOURCE_METADATA_KEY: &str = "runtime.session_start_source";
35
36/// Prepare a chat turn: load/create session, resolve prompts, update metadata,
37/// append user message, persist.
38///
39/// Returns the prepared session ready for execution.
40///
41/// **Note**: Image handling and workspace sync (`ensure_session_workspace`)
42/// are NOT included here — those remain in the handler layer.
43pub async fn prepare_chat_turn(
44    repo: &dyn SessionAccess,
45    input: ChatTurnInput,
46    global_default_prompt: &str,
47    builtin_fallback_prompt: &str,
48) -> Result<Session, ChatError> {
49    prepare_chat_turn_inner(
50        repo,
51        input,
52        global_default_prompt,
53        builtin_fallback_prompt,
54        true,
55    )
56    .await
57}
58
59/// Prepare a chat turn without persisting it.
60///
61/// The server uses this variant so its authoritative Project/workspace
62/// resolver can run after the second session load and before any session or
63/// runtime workspace side effect. Callers must save the returned session after
64/// their external invariants pass.
65pub async fn prepare_chat_turn_unpersisted(
66    repo: &dyn SessionAccess,
67    input: ChatTurnInput,
68    global_default_prompt: &str,
69    builtin_fallback_prompt: &str,
70) -> Result<Session, ChatError> {
71    prepare_chat_turn_inner(
72        repo,
73        input,
74        global_default_prompt,
75        builtin_fallback_prompt,
76        false,
77    )
78    .await
79}
80
81async fn prepare_chat_turn_inner(
82    repo: &dyn SessionAccess,
83    input: ChatTurnInput,
84    global_default_prompt: &str,
85    builtin_fallback_prompt: &str,
86    persist: bool,
87) -> Result<Session, ChatError> {
88    let existing = repo.load_merged(&input.session_id).await?;
89    let mut session = prepare_chat_turn_from_authoritative_session(
90        existing,
91        input,
92        global_default_prompt,
93        builtin_fallback_prompt,
94    )?;
95    if persist {
96        repo.save_and_cache(&mut session).await?;
97    }
98    Ok(session)
99}
100
101/// Prepare a turn from a caller-supplied authoritative durable snapshot.
102///
103/// HTTP transaction paths acquire their per-session persistence lock, load
104/// directly from `LockedSessionStore::storage()`, and call this function so a
105/// stale cache can never win the Project membership re-check.
106pub fn prepare_chat_turn_from_authoritative_session(
107    existing: Option<Session>,
108    input: ChatTurnInput,
109    global_default_prompt: &str,
110    builtin_fallback_prompt: &str,
111) -> Result<Session, ChatError> {
112    prepare_chat_turn_from_authoritative_session_with_workspace_policy(
113        existing,
114        input,
115        global_default_prompt,
116        builtin_fallback_prompt,
117        ChatWorkspaceFallbackPolicy::Legacy,
118    )
119}
120
121/// Prepare a turn with an explicit caller-owned workspace fallback policy.
122///
123/// The server uses this entrypoint after loading its own live config snapshot
124/// and previewing its AppState-scoped session fallback. Existing callers use
125/// [`prepare_chat_turn_from_authoritative_session`] and preserve legacy
126/// process-global/data-directory fallback behavior.
127pub fn prepare_chat_turn_from_authoritative_session_with_workspace_policy(
128    existing: Option<Session>,
129    input: ChatTurnInput,
130    global_default_prompt: &str,
131    builtin_fallback_prompt: &str,
132    workspace_fallback_policy: ChatWorkspaceFallbackPolicy,
133) -> Result<Session, ChatError> {
134    let (mut session, session_start_source) = match existing {
135        Some(session) => (session, "resume"),
136        None => (
137            Session::new(input.session_id.clone(), input.model.clone()),
138            "startup",
139        ),
140    };
141    match crate::project_context::ProjectContextResolver::session_project_identity(&session) {
142        crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
143            return Err(ChatError::InvalidProjectIdentity { raw, message });
144        }
145        crate::project_context::SessionProjectIdentity::Assigned(actual)
146            if input.project_id.as_ref() != Some(&actual) =>
147        {
148            return Err(ChatError::ProjectIdentityConflict {
149                expected: input.project_id,
150                actual: Some(actual),
151            });
152        }
153        crate::project_context::SessionProjectIdentity::Unassigned
154            if session_start_source == "resume" && input.project_id.is_some() =>
155        {
156            return Err(ChatError::ProjectIdentityConflict {
157                expected: input.project_id,
158                actual: None,
159            });
160        }
161        crate::project_context::SessionProjectIdentity::Assigned(_)
162        | crate::project_context::SessionProjectIdentity::Unassigned => {}
163    }
164    crate::runtime::runner::session_setup::migrate_legacy_workspace_prompt(&mut session);
165    session.metadata.insert(
166        SESSION_START_SOURCE_METADATA_KEY.to_string(),
167        session_start_source.to_string(),
168    );
169    if session_start_source == "startup" {
170        if let Some(project_id) = input.project_id.as_ref() {
171            session.set_project_id_meta(project_id.to_string());
172        }
173        session.reasoning_effort = input.reasoning_effort;
174        // Stamp the resolved initial permission mode exactly once at session
175        // creation. The typed mode and legacy boolean mirror stay consistent
176        // via `set_permission_mode`; later changes go through PATCH.
177        if let Some(mode) = input.permission_mode {
178            session
179                .agent_runtime_state
180                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
181                .set_permission_mode(mode);
182        }
183    }
184
185    // ---- Resolve base prompt ----
186    let base_prompt = resolve_base_prompt(
187        &mut session,
188        input.system_prompt.as_deref(),
189        global_default_prompt,
190        builtin_fallback_prompt,
191    );
192
193    // ---- Resolve enhance prompt ----
194    resolve_enhance_prompt(&mut session, input.enhance_prompt.as_deref());
195    let enhance_prompt = session.enhance_prompt();
196    if let Some(enabled) = input.root_orchestration_prompt {
197        // Only a root can select this host-owned prompt. The runtime repeats
198        // the same root check before model assembly for persisted sessions.
199        if session.kind == bamboo_domain::SessionKind::Root && session.parent_session_id.is_none() {
200            session.set_root_orchestration_prompt_enabled(enabled);
201        }
202    }
203
204    // ---- Resolve workspace path (metadata only, no filesystem) ----
205    let allow_legacy_workspace_fallback = matches!(
206        crate::project_context::ProjectContextResolver::session_project_identity(&session),
207        crate::project_context::SessionProjectIdentity::Unassigned
208    );
209    let workspace_path = resolve_workspace_path_with_default(
210        &mut session,
211        input.workspace_path.as_deref(),
212        input.default_workspace_path.as_deref(),
213        &workspace_fallback_policy,
214        input.data_dir.as_deref(),
215        allow_legacy_workspace_fallback,
216    );
217
218    // ---- Resolve typed workflow selection / legacy skill IDs ----
219    resolve_workflow_selection(
220        &mut session,
221        input.workflow_selection.as_ref(),
222        input.selected_skill_ids.as_deref(),
223        &input.message,
224    )?;
225    if let Some(enabled) = input.root_orchestration_only {
226        session.set_root_orchestration_only(enabled)?;
227    }
228    if session.model_orchestration_only_enabled() {
229        session.validate_root_orchestration_compatibility()?;
230        // A typed Workflow can remain pending or active without a valid
231        // selected-skill mirror. Treat malformed authority as incompatible.
232        let pending = session
233            .metadata
234            .contains_key(WORKFLOW_SELECTION_METADATA_KEY);
235        let active = session
236            .metadata
237            .get(ACTIVE_WORKFLOW_METADATA_KEY)
238            .is_some_and(|raw| {
239                serde_json::from_str::<ActiveWorkflow>(raw)
240                    .map(|workflow| workflow.status != WorkflowActivationStatus::Deactivated)
241                    .unwrap_or(true)
242            });
243        if pending || active {
244            return Err(bamboo_domain::RootToolAuthorityError::WorkflowSelected.into());
245        }
246    }
247    if let Some(opted_in) = input.orchestration_opt_in {
248        session.metadata.insert(
249            WORKFLOW_ORCHESTRATION_OPT_IN_METADATA_KEY.to_string(),
250            opted_in.to_string(),
251        );
252    }
253
254    // ---- Build enhanced system prompt with profile ----
255    let (system_prompt, prompt_profile) = build_enhanced_system_prompt_with_profile(
256        &base_prompt,
257        enhance_prompt.as_deref(),
258        workspace_path.as_deref(),
259    );
260
261    session.metadata.insert(
262        PROMPT_COMPOSER_VERSION_KEY.to_string(),
263        prompt_profile.version.to_string(),
264    );
265    session.metadata.insert(
266        PROMPT_FINGERPRINT_KEY.to_string(),
267        prompt_profile.fingerprint.clone(),
268    );
269    session.metadata.insert(
270        PROMPT_COMPONENT_FLAGS_KEY.to_string(),
271        prompt_profile.component_flags_value(),
272    );
273    session.metadata.insert(
274        PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
275        prompt_profile.component_lengths_value(),
276    );
277
278    // ---- Upsert system prompt message ----
279    // A workspace-only switch leaves this stable text byte-identical and must
280    // not rewrite Session.messages.
281    let system_messages = session
282        .messages
283        .iter()
284        .filter(|message| matches!(message.role, Role::System))
285        .collect::<Vec<_>>();
286    let system_is_current = system_messages.len() == 1
287        && system_messages[0].content == system_prompt
288        && session
289            .messages
290            .first()
291            .is_some_and(|message| matches!(message.role, Role::System));
292    if !system_is_current {
293        session
294            .messages
295            .retain(|message| !matches!(message.role, Role::System));
296        session.messages.insert(0, Message::system(system_prompt));
297    }
298    refresh_prompt_snapshot(&mut session);
299
300    // ---- Persist model/provider selection ----
301    let request_model_ref = derive_model_ref(
302        input.model_ref.as_ref(),
303        input.provider.as_deref(),
304        Some(input.model.as_str()),
305    );
306    if let Some(model_ref) = request_model_ref.as_ref() {
307        persist_model_ref(&mut session, model_ref);
308    } else {
309        persist_legacy_model_provider(
310            &mut session,
311            Some(input.model.as_str()),
312            input.provider.as_deref(),
313        );
314    }
315
316    Ok(session)
317}
318
319// ---- Internal helpers ----
320
321pub fn resolve_base_prompt(
322    session: &mut Session,
323    base_prompt_from_request: Option<&str>,
324    global_default_template: &str,
325    builtin_fallback: &str,
326) -> String {
327    let resolved = base_prompt_from_request
328        .map(ToString::to_string)
329        .or_else(|| {
330            session
331                .metadata
332                .get(BASE_SYSTEM_PROMPT_KEY)
333                .map(String::as_str)
334                .map(str::trim)
335                .filter(|value| !value.is_empty())
336                .map(ToString::to_string)
337        })
338        .or_else(|| {
339            session
340                .messages
341                .iter()
342                .find(|message| matches!(message.role, Role::System))
343                .map(|message| message.content.trim().to_string())
344                .filter(|value| !value.is_empty())
345        })
346        .unwrap_or_else(|| {
347            let trimmed = global_default_template.trim();
348            if trimmed.is_empty() {
349                builtin_fallback.to_string()
350            } else {
351                trimmed.to_string()
352            }
353        });
354    let resolved =
355        crate::runtime::runner::session_setup::prompt_setup::normalize_base_prompt(&resolved);
356
357    session
358        .metadata
359        .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
360    resolved
361}
362
363pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
364    if let Some(prompt) = enhance_prompt_from_request {
365        session.set_enhance_prompt(prompt);
366    } else {
367        session.clear_enhance_prompt();
368    }
369}
370
371pub fn resolve_workspace_path(
372    session: &mut Session,
373    workspace_path_from_request: Option<&str>,
374    data_dir: Option<&Path>,
375) -> Option<String> {
376    resolve_workspace_path_with_default(
377        session,
378        workspace_path_from_request,
379        None,
380        &ChatWorkspaceFallbackPolicy::Legacy,
381        data_dir,
382        true,
383    )
384}
385
386fn resolve_workspace_path_with_default(
387    session: &mut Session,
388    workspace_path_from_request: Option<&str>,
389    default_workspace_path: Option<&str>,
390    fallback_policy: &ChatWorkspaceFallbackPolicy,
391    data_dir: Option<&Path>,
392    allow_legacy_fallback: bool,
393) -> Option<String> {
394    let explicit = workspace_path_from_request
395        .map(str::trim)
396        .filter(|value| !value.is_empty())
397        .map(ToString::to_string);
398    let persisted = session.workspace_path_meta();
399    let configured_default = allow_legacy_fallback
400        .then(|| {
401            default_workspace_path
402                .map(str::trim)
403                .filter(|value| !value.is_empty())
404                .map(ToString::to_string)
405        })
406        .flatten();
407    let fallback = (explicit.is_none() && persisted.is_none() && configured_default.is_none())
408        .then(|| {
409            allow_legacy_fallback
410                .then(|| {
411                    resolve_workspace_fallback_with(fallback_policy, || {
412                        resolve_default_workspace(data_dir)
413                    })
414                })
415                .flatten()
416        })
417        .flatten();
418    let (resolved, source, binding_reset) = if let Some(workspace) = explicit {
419        (
420            Some(workspace),
421            crate::project_context::WorkspaceSource::Explicit,
422            true,
423        )
424    } else if let Some(workspace) = persisted {
425        (
426            Some(workspace),
427            crate::project_context::WorkspaceSource::from_metadata(
428                session
429                    .metadata
430                    .get(crate::project_context::WORKSPACE_SOURCE_METADATA_KEY)
431                    .map(String::as_str),
432            ),
433            false,
434        )
435    } else if let Some(workspace) = configured_default {
436        (
437            Some(workspace),
438            crate::project_context::WorkspaceSource::Session,
439            true,
440        )
441    } else {
442        (
443            fallback,
444            crate::project_context::WorkspaceSource::Session,
445            true,
446        )
447    };
448    if let Some(workspace) = resolved.as_ref() {
449        // Persist the effective post-lock choice, including a live-config or
450        // session-root fallback. Otherwise the subsequent Project resolver
451        // would see no metadata and independently pick a different global
452        // fallback.
453        session.set_workspace_path_meta(workspace.clone());
454        session.metadata.insert(
455            crate::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
456            source.as_str().to_string(),
457        );
458        if binding_reset {
459            session.metadata.insert(
460                crate::project_context::WORKSPACE_BINDING_STATUS_METADATA_KEY.to_string(),
461                crate::project_context::WorkspaceBindingStatus::Unregistered
462                    .as_str()
463                    .to_string(),
464            );
465        }
466    }
467    resolved
468}
469
470fn resolve_workspace_fallback_with<L>(
471    fallback_policy: &ChatWorkspaceFallbackPolicy,
472    legacy_lookup: L,
473) -> Option<String>
474where
475    L: FnOnce() -> Option<String>,
476{
477    match fallback_policy {
478        ChatWorkspaceFallbackPolicy::Legacy => legacy_lookup(),
479        ChatWorkspaceFallbackPolicy::Authoritative {
480            session_fallback_path,
481        } => session_fallback_path.clone(),
482    }
483}
484
485/// Resolve the configured default workspace (display string), preferring the
486/// server's live in-memory config.
487///
488/// If a workspace provider IS registered (the server, which owns the live
489/// `Arc<RwLock<Config>>`), it is AUTHORITATIVE: we use its result and never disk
490/// read — even when it resolves to `None` (no default work area configured).
491/// That closes the divergent disk read + global env-var-cache clobber for the
492/// whole server runtime (#38 / #131). Only when NO provider is registered
493/// (non-server contexts — SDK / CLI / unit tests) do we fall back to a direct
494/// `from_data_dir` read of `data_dir`.
495fn resolve_default_workspace(data_dir: Option<&Path>) -> Option<String> {
496    let configured = if bamboo_agent_core::workspace_state::has_default_workspace_provider() {
497        bamboo_agent_core::workspace_state::get_configured_default_workspace()
498    } else {
499        default_workspace_from_data_dir(data_dir)
500    };
501    configured.map(|path| path_to_display_string(&path))
502}
503
504/// Legacy non-server fallback: load `{data_dir}/config.json` from disk and read
505/// its default work area. Only used when no workspace provider is registered.
506fn default_workspace_from_data_dir(data_dir: Option<&Path>) -> Option<PathBuf> {
507    bamboo_llm::Config::from_data_dir(data_dir.map(Path::to_path_buf)).get_default_work_area_path()
508}
509
510pub fn resolve_selected_skill_ids(
511    session: &mut Session,
512    selected_skill_ids_from_request: Option<&[String]>,
513    message: &str,
514) {
515    if let Some(request_ids) = selected_skill_ids_from_request {
516        let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
517        persist_selected_skill_ids_metadata(session, normalized.as_deref());
518        return;
519    }
520
521    let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
522    if let Some(ids) = from_hint.as_ref() {
523        persist_selected_skill_ids_metadata(session, Some(ids));
524        return;
525    }
526
527    session.clear_selected_skill_ids();
528}
529
530pub fn resolve_workflow_selection(
531    session: &mut Session,
532    workflow_selection: Option<&WorkflowSelection>,
533    selected_skill_ids_from_request: Option<&[String]>,
534    message: &str,
535) -> Result<(), ChatError> {
536    if let Some(selection) = workflow_selection {
537        let id = selection.id.trim();
538        if id.is_empty() || selection.revision == 0 || !selection.args.is_object() {
539            return Err(ChatError::InvalidWorkflowSelection(
540                "id must be non-empty, revision must be positive, and args must be an object"
541                    .to_string(),
542            ));
543        }
544        let previous = session
545            .metadata
546            .get(WORKFLOW_SELECTION_METADATA_KEY)
547            .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok());
548        let selection_changed = previous.as_ref() != Some(selection);
549        session.metadata.insert(
550            WORKFLOW_SELECTION_METADATA_KEY.to_string(),
551            serde_json::to_string(selection).map_err(|_| {
552                ChatError::InvalidWorkflowSelection("selection cannot be serialized".to_string())
553            })?,
554        );
555        persist_selected_skill_ids_metadata(session, Some(&[id.to_string()]));
556        if selection_changed {
557            deactivate_active_workflow(session);
558            clear_skill_runtime_state(session);
559        }
560        return Ok(());
561    }
562
563    if selected_skill_ids_from_request.is_some() {
564        // Legacy explicit selection remains compatible, but can never override
565        // an authoritative typed selection in the same request.
566        session.metadata.remove(WORKFLOW_SELECTION_METADATA_KEY);
567        deactivate_active_workflow(session);
568        resolve_selected_skill_ids(session, selected_skill_ids_from_request, message);
569        clear_skill_runtime_state(session);
570        return Ok(());
571    }
572
573    // A typed chat candidate is durable authority for the next execute, even
574    // when another ordinary message is appended before execution starts.
575    // Keep its visible selected id aligned with the retained immutable
576    // snapshot; only an explicit replacement/deactivation may cancel it.
577    if let Some(pending) = session
578        .metadata
579        .get(WORKFLOW_SELECTION_METADATA_KEY)
580        .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok())
581        .filter(|_| {
582            session
583                .metadata
584                .get(bamboo_skills::runtime_metadata::SKILL_RUNTIME_SELECTION_SOURCE_KEY)
585                .is_some_and(|source| source == "explicit")
586                && session.metadata.contains_key(
587                    bamboo_skills::runtime_metadata::SKILL_RUNTIME_PINNED_SNAPSHOT_KEY,
588                )
589        })
590    {
591        persist_selected_skill_ids_metadata(session, Some(&[pending.id]));
592        return Ok(());
593    }
594
595    if let Some(active) = session
596        .metadata
597        .get(ACTIVE_WORKFLOW_METADATA_KEY)
598        .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
599        .filter(|active| active.status == WorkflowActivationStatus::Active)
600    {
601        persist_selected_skill_ids_metadata(session, Some(&[active.id]));
602        return Ok(());
603    }
604
605    // Legacy natural-language hints are parsed only when there is no typed or
606    // durable active workflow. They remain compatibility input, never authority.
607    resolve_selected_skill_ids(session, None, message);
608    Ok(())
609}
610
611fn deactivate_active_workflow(session: &mut Session) {
612    if let Some(active) = session
613        .metadata
614        .get(ACTIVE_WORKFLOW_METADATA_KEY)
615        .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
616        .filter(|active| active.status == WorkflowActivationStatus::Active)
617    {
618        session.metadata.insert(
619            WORKFLOW_ACTIVATION_EVENT_METADATA_KEY.to_string(),
620            serde_json::json!({
621                "type": "workflow.deactivated",
622                "workflow_id": active.id,
623                "revision": active.revision,
624                "deactivated_at": chrono::Utc::now(),
625            })
626            .to_string(),
627        );
628    }
629    session.metadata.remove(ACTIVE_WORKFLOW_METADATA_KEY);
630    session
631        .metadata
632        .remove(ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY);
633}
634
635/// Clear skill runtime state markers from session metadata.
636pub fn clear_skill_runtime_state(session: &mut Session) {
637    session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
638    session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
639}
640
641/// Retire legacy executable state before publishing a different resource scope.
642/// Keep host Skill restrictions and ordinary history: removing selected_skill_ids
643/// would widen the Native execution ceiling. No new snapshot format is required.
644pub fn clear_workflow_authority_for_resource_scope_change(session: &mut Session) {
645    let identity = session
646        .metadata
647        .get(ACTIVE_WORKFLOW_METADATA_KEY)
648        .and_then(|raw| serde_json::from_str::<ActiveWorkflow>(raw).ok())
649        .map(|active| (active.id, active.revision))
650        .or_else(|| {
651            session
652                .metadata
653                .get(WORKFLOW_SELECTION_METADATA_KEY)
654                .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok())
655                .map(|selection| (selection.id, selection.revision))
656        });
657    session.metadata.retain(|key, _| {
658        !key.starts_with("skill_runtime_")
659            && !matches!(
660                key.as_str(),
661                ACTIVE_WORKFLOW_METADATA_KEY
662                    | ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY
663                    | WORKFLOW_SELECTION_METADATA_KEY
664                    | WORKFLOW_ACTIVATION_EVENT_METADATA_KEY
665                    | WORKFLOW_CONTEXT_CACHE_METADATA_KEY
666                    | WORKFLOW_LAST_DYNAMIC_CONTEXT_METADATA_KEY
667                    | WORKFLOW_CATALOG_DIAGNOSTIC_METADATA_KEY
668                    | "skill.context"
669            )
670    });
671    if let Some((workflow_id, revision)) = identity {
672        session.metadata.insert(
673            WORKFLOW_ACTIVATION_EVENT_METADATA_KEY.to_string(),
674            serde_json::json!({
675                "type": "workflow.deactivated",
676                "workflow_id": workflow_id,
677                "revision": revision,
678                "reason": "resource_scope_changed",
679                "deactivated_at": chrono::Utc::now(),
680            })
681            .to_string(),
682        );
683    }
684}
685
686fn persist_selected_skill_ids_metadata(
687    session: &mut Session,
688    selected_skill_ids: Option<&[String]>,
689) {
690    match selected_skill_ids {
691        Some(ids) if !ids.is_empty() => {
692            session.set_selected_skill_ids(ids.to_vec());
693        }
694        _ => {
695            session.clear_selected_skill_ids();
696        }
697    }
698}
699
700// ---- Goal command parsing ----
701
702/// Parsed result of a `/goal` command.
703#[derive(Debug, Clone, PartialEq, Eq)]
704pub enum GoalCommand {
705    /// `/goal status` or bare `/goal` — read-only status query.
706    Status,
707    /// `/goal off` or `/goal disable` or `/goal disabled`.
708    Off,
709    /// `/goal clear` or `/goal reset`.
710    Clear,
711    /// `/goal on` or `/goal enable` or `/goal enabled`.
712    On,
713    /// `/goal <prompt text>` — set the goal evaluation prompt and enable.
714    SetPrompt(String),
715}
716
717/// Attempt to parse a `/goal` command from the raw user message.
718/// Returns `None` if the message is not a `/goal` command.
719pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
720    let trimmed = message.trim();
721    if !trimmed.to_ascii_lowercase().starts_with("/goal") {
722        return None;
723    }
724    // Ensure "/goal" is followed by end-of-string or whitespace (not "/goalpost").
725    let rest = &trimmed[5..]; // skip "/goal"
726    if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
727        return None;
728    }
729
730    let arg = rest.trim().to_ascii_lowercase();
731
732    if arg.is_empty() {
733        return Some(GoalCommand::Status);
734    }
735
736    match arg.as_str() {
737        "status" => Some(GoalCommand::Status),
738        "off" | "disable" | "disabled" => Some(GoalCommand::Off),
739        "clear" | "reset" => Some(GoalCommand::Clear),
740        "on" | "enable" | "enabled" => Some(GoalCommand::On),
741        _ => {
742            // Everything else is treated as the goal prompt text.
743            // Use the original (non-lowercased) arg to preserve casing.
744            let prompt = trimmed
745                .strip_prefix("/goal")
746                .unwrap_or(trimmed)
747                .trim()
748                .to_string();
749            if prompt.is_empty() {
750                Some(GoalCommand::Status)
751            } else {
752                Some(GoalCommand::SetPrompt(prompt))
753            }
754        }
755    }
756}
757
758fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
759    const HINT_PREFIX: &str = "[User explicitly selected skill:";
760    let mut extracted = Vec::new();
761
762    for line in message.lines() {
763        let trimmed = line.trim();
764        if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
765            continue;
766        }
767
768        let Some(id_marker_index) = trimmed.rfind("(ID:") else {
769            continue;
770        };
771        let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
772        let Some(close_paren_index) = id_segment.find(')') else {
773            continue;
774        };
775        let id = id_segment[..close_paren_index].trim();
776        if !id.is_empty() {
777            extracted.push(id.to_string());
778        }
779    }
780
781    extracted
782}
783
784// ---- Prompt building ----
785
786#[derive(Debug, Clone, PartialEq, Eq)]
787struct PromptCompositionProfile {
788    version: &'static str,
789    fingerprint: String,
790    has_enhancement: bool,
791    has_workspace_context: bool,
792    has_env_context: bool,
793    base_len: usize,
794    enhancement_len: usize,
795    workspace_context_len: usize,
796    env_context_len: usize,
797    final_len: usize,
798}
799
800impl PromptCompositionProfile {
801    fn component_flags_value(&self) -> String {
802        format!(
803            "enhance={};workspace={};env={}",
804            self.has_enhancement as u8,
805            self.has_workspace_context as u8,
806            self.has_env_context as u8,
807        )
808    }
809
810    fn component_lengths_value(&self) -> String {
811        format!(
812            "base={};enhance={};workspace={};env={};final={}",
813            self.base_len,
814            self.enhancement_len,
815            self.workspace_context_len,
816            self.env_context_len,
817            self.final_len
818        )
819    }
820}
821
822fn build_prompt_fingerprint(base_prompt: &str, enhancement: Option<&str>) -> String {
823    let mut hasher = Sha256::new();
824    hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
825    hasher.update([0u8]);
826    hasher.update(base_prompt.as_bytes());
827    hasher.update([0u8]);
828    hasher.update(enhancement.unwrap_or_default().as_bytes());
829    hex::encode(hasher.finalize())
830}
831
832fn build_enhanced_system_prompt_with_profile(
833    base_prompt: &str,
834    enhance_prompt: Option<&str>,
835    workspace_path: Option<&str>,
836) -> (String, PromptCompositionProfile) {
837    let mut merged_prompt = base_prompt.to_string();
838
839    let enhancement = enhance_prompt
840        .map(str::trim)
841        .filter(|enhancement| !enhancement.is_empty())
842        .map(ToString::to_string);
843    if let Some(enhancement) = enhancement.as_ref() {
844        merged_prompt.push_str("\n\n");
845        merged_prompt.push_str(enhancement.as_str());
846    }
847
848    let has_workspace_context = workspace_path
849        .map(str::trim)
850        .is_some_and(|workspace_path| !workspace_path.is_empty());
851
852    // Environment details are discovered from the same authority during round
853    // assembly and emitted as a typed EnvSnapshot. Keep the compatibility flag
854    // observable without copying those details into System or its fingerprint.
855    let has_env_context = build_env_prompt_context().is_some();
856
857    let profile = PromptCompositionProfile {
858        version: PROMPT_COMPOSER_VERSION,
859        fingerprint: build_prompt_fingerprint(base_prompt, enhancement.as_deref()),
860        has_enhancement: enhancement.is_some(),
861        has_workspace_context,
862        has_env_context,
863        base_len: base_prompt.len(),
864        enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
865        workspace_context_len: 0,
866        env_context_len: 0,
867        final_len: merged_prompt.len(),
868    };
869
870    (merged_prompt, profile)
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876    use crate::session_app::errors::{SessionLoadError, SessionSaveError};
877    use async_trait::async_trait;
878    use std::sync::atomic::{AtomicUsize, Ordering};
879    use std::sync::{Arc, Mutex};
880
881    struct InMemorySessionAccess;
882
883    struct ExistingSessionAccess(Session);
884
885    struct BarrierSessionAccess {
886        session: Mutex<Session>,
887        load_started: tokio::sync::Notify,
888        release_load: tokio::sync::Notify,
889        save_count: AtomicUsize,
890    }
891
892    #[async_trait]
893    impl SessionAccess for InMemorySessionAccess {
894        async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
895            Ok(None)
896        }
897
898        async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
899            Ok(Session::new(id, model))
900        }
901
902        async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
903            Ok(None)
904        }
905
906        async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
907            Ok(())
908        }
909
910        async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
911            Ok(())
912        }
913    }
914
915    #[async_trait]
916    impl SessionAccess for ExistingSessionAccess {
917        async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
918            Ok(Some(self.0.clone()))
919        }
920
921        async fn load_or_create(
922            &self,
923            _id: &str,
924            _model: &str,
925        ) -> Result<Session, SessionLoadError> {
926            panic!("existing chat session must not be recreated")
927        }
928
929        async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
930            Ok(Some(self.0.clone()))
931        }
932
933        async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
934            Ok(())
935        }
936
937        async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
938            Ok(())
939        }
940    }
941
942    #[async_trait]
943    impl SessionAccess for BarrierSessionAccess {
944        async fn load_session(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
945            Ok(Some(self.session.lock().expect("session lock").clone()))
946        }
947
948        async fn load_or_create(
949            &self,
950            _id: &str,
951            _model: &str,
952        ) -> Result<Session, SessionLoadError> {
953            panic!("barrier session already exists")
954        }
955
956        async fn load_merged(&self, _id: &str) -> Result<Option<Session>, SessionLoadError> {
957            self.load_started.notify_one();
958            self.release_load.notified().await;
959            Ok(Some(self.session.lock().expect("session lock").clone()))
960        }
961
962        async fn save_session(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
963            self.save_count.fetch_add(1, Ordering::SeqCst);
964            Ok(())
965        }
966
967        async fn save_and_cache(&self, _session: &mut Session) -> Result<(), SessionSaveError> {
968            self.save_count.fetch_add(1, Ordering::SeqCst);
969            Ok(())
970        }
971    }
972
973    fn chat_turn_input(enhance_prompt: Option<&str>) -> super::super::types::ChatTurnInput {
974        super::super::types::ChatTurnInput {
975            session_id: "session-enhance".to_string(),
976            project_id: None,
977            model: "gpt-5".to_string(),
978            model_ref: None,
979            provider: None,
980            reasoning_effort: None,
981            message: "hello".to_string(),
982            system_prompt: Some("Base prompt".to_string()),
983            enhance_prompt: enhance_prompt.map(ToString::to_string),
984            root_orchestration_prompt: None,
985            root_orchestration_only: None,
986            workspace_path: None,
987            permission_mode: None,
988            default_workspace_path: None,
989            selected_skill_ids: None,
990            workflow_selection: None,
991            orchestration_opt_in: None,
992            data_dir: None,
993        }
994    }
995
996    fn system_message_content(session: &Session) -> String {
997        session
998            .messages
999            .iter()
1000            .find(|message| matches!(message.role, Role::System))
1001            .map(|message| message.content.clone())
1002            .expect("session should have a system message")
1003    }
1004
1005    #[test]
1006    fn new_chat_session_persists_explicit_reasoning_profile() {
1007        let mut input = chat_turn_input(None);
1008        input.reasoning_effort = Some(bamboo_domain::ReasoningEffort::Xhigh);
1009
1010        let session = prepare_chat_turn_from_authoritative_session(
1011            None,
1012            input,
1013            "Global prompt",
1014            "Builtin prompt",
1015        )
1016        .expect("new chat session");
1017
1018        assert_eq!(
1019            session.reasoning_effort,
1020            Some(bamboo_domain::ReasoningEffort::Xhigh)
1021        );
1022    }
1023
1024    fn active_workflow(id: &str, revision: u64) -> ActiveWorkflow {
1025        ActiveWorkflow {
1026            id: id.to_string(),
1027            source: bamboo_skills::WorkflowSource::User,
1028            revision,
1029            kind: bamboo_skills::WorkflowKind::Instruction,
1030            args: serde_json::json!({}),
1031            invoked_by: bamboo_skills::WorkflowInvokedBy::User,
1032            activated_at: chrono::Utc::now(),
1033            status: WorkflowActivationStatus::Active,
1034            diagnostic: None,
1035            context_fingerprint: Some("fingerprint".to_string()),
1036            dynamic_context: Vec::new(),
1037        }
1038    }
1039
1040    #[tokio::test]
1041    async fn prepare_chat_turn_classifies_new_and_existing_sessions() {
1042        let startup = prepare_chat_turn(
1043            &InMemorySessionAccess,
1044            chat_turn_input(None),
1045            "global",
1046            "builtin",
1047        )
1048        .await
1049        .expect("new turn");
1050        assert_eq!(
1051            startup
1052                .metadata
1053                .get(SESSION_START_SOURCE_METADATA_KEY)
1054                .map(String::as_str),
1055            Some("startup")
1056        );
1057
1058        let existing = prepare_chat_turn(
1059            &ExistingSessionAccess(Session::new("session-enhance", "gpt-5")),
1060            chat_turn_input(None),
1061            "global",
1062            "builtin",
1063        )
1064        .await
1065        .expect("existing turn");
1066        assert_eq!(
1067            existing
1068                .metadata
1069                .get(SESSION_START_SOURCE_METADATA_KEY)
1070                .map(String::as_str),
1071            Some("resume")
1072        );
1073    }
1074
1075    #[tokio::test]
1076    async fn prepare_chat_turn_rechecks_project_identity_after_preflight_race() {
1077        let project_a =
1078            bamboo_domain::ProjectId::parse("project-chat-a").expect("Project A identity");
1079        let project_b =
1080            bamboo_domain::ProjectId::parse("project-chat-b").expect("Project B identity");
1081        let workspace_a = tempfile::tempdir().expect("workspace A");
1082        let workspace_b = tempfile::tempdir().expect("workspace B");
1083        let mut session = Session::new("chat-project-race", "gpt-5");
1084        session.set_project_id_meta(project_a.to_string());
1085        session.set_workspace_path_meta(workspace_a.path().to_string_lossy().into_owned());
1086        let repo = Arc::new(BarrierSessionAccess {
1087            session: Mutex::new(session),
1088            load_started: tokio::sync::Notify::new(),
1089            release_load: tokio::sync::Notify::new(),
1090            save_count: AtomicUsize::new(0),
1091        });
1092        let mut input = chat_turn_input(None);
1093        input.session_id = "chat-project-race".to_string();
1094        input.project_id = Some(project_a.clone());
1095        input.workspace_path = Some(workspace_a.path().to_string_lossy().into_owned());
1096
1097        let task_repo = repo.clone();
1098        let task = tokio::spawn(async move {
1099            prepare_chat_turn(task_repo.as_ref(), input, "global", "builtin").await
1100        });
1101        repo.load_started.notified().await;
1102        {
1103            let mut raced = repo.session.lock().expect("session lock");
1104            raced.set_project_id_meta(project_b.to_string());
1105            raced.set_workspace_path_meta(workspace_b.path().to_string_lossy().into_owned());
1106        }
1107        repo.release_load.notify_one();
1108
1109        let error = task
1110            .await
1111            .expect("chat task")
1112            .expect_err("membership race must fail closed");
1113        assert!(matches!(
1114            error,
1115            ChatError::ProjectIdentityConflict {
1116                expected: Some(expected),
1117                actual: Some(actual),
1118            } if expected == project_a && actual == project_b
1119        ));
1120        assert_eq!(repo.save_count.load(Ordering::SeqCst), 0);
1121        let persisted = repo.session.lock().expect("session lock");
1122        assert_eq!(
1123            persisted.workspace_path_meta().as_deref(),
1124            Some(workspace_b.path().to_string_lossy().as_ref())
1125        );
1126        assert!(persisted.messages.is_empty());
1127        assert!(!persisted
1128            .metadata
1129            .contains_key(SESSION_START_SOURCE_METADATA_KEY));
1130    }
1131
1132    #[test]
1133    fn typed_workflow_selection_is_authoritative_over_legacy_ids_and_hint() {
1134        let mut session = Session::new("typed-selection", "model");
1135        let selection = WorkflowSelection {
1136            id: "review".to_string(),
1137            source: bamboo_skills::WorkflowSource::User,
1138            revision: 7,
1139            args: serde_json::json!({"depth": "full"}),
1140        };
1141        resolve_workflow_selection(
1142            &mut session,
1143            Some(&selection),
1144            Some(&["plan".to_string()]),
1145            "use skill plan",
1146        )
1147        .expect("typed selection");
1148        assert_eq!(
1149            session.selected_skill_ids(),
1150            Some(vec!["review".to_string()])
1151        );
1152        assert_eq!(
1153            session
1154                .metadata
1155                .get(WORKFLOW_SELECTION_METADATA_KEY)
1156                .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok()),
1157            Some(selection)
1158        );
1159    }
1160
1161    #[test]
1162    fn active_workflow_survives_turn_without_new_selection() {
1163        let mut session = Session::new("active-selection", "model");
1164        session.metadata.insert(
1165            ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
1166            serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
1167        );
1168        resolve_workflow_selection(&mut session, None, None, "use skill plan")
1169            .expect("preserve active");
1170        assert_eq!(
1171            session.selected_skill_ids(),
1172            Some(vec!["review".to_string()])
1173        );
1174        assert!(session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
1175    }
1176
1177    #[test]
1178    fn resource_scope_cleanup_removes_recovery_bytes_without_widening_skill_ceiling() {
1179        let mut session = Session::new("scope-cleanup", "model");
1180        session.set_selected_skill_ids(vec!["review".into()]);
1181        session.add_message(Message::user("Keep the prior request"));
1182        session.add_message(Message::assistant("Keep the prior result", None));
1183        let history = session.messages.clone();
1184        session
1185            .metadata
1186            .insert("permission_mode".into(), "default".into());
1187        session.metadata.insert("skill_mode".into(), "code".into());
1188        session
1189            .metadata
1190            .insert("workflow.run_ids.v1".into(), r#"["run-1"]"#.into());
1191        session.metadata.insert(
1192            ACTIVE_WORKFLOW_METADATA_KEY.into(),
1193            serde_json::to_string(&active_workflow("review", 7)).unwrap(),
1194        );
1195        for key in [
1196            ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY,
1197            WORKFLOW_CONTEXT_CACHE_METADATA_KEY,
1198            WORKFLOW_LAST_DYNAMIC_CONTEXT_METADATA_KEY,
1199            "skill_runtime_pinned_snapshot_v1",
1200            "skill_runtime_last_load_summary",
1201            "skill.context",
1202        ] {
1203            session
1204                .metadata
1205                .insert(key.into(), "old private bytes".into());
1206        }
1207
1208        clear_workflow_authority_for_resource_scope_change(&mut session);
1209
1210        let restored: Session = serde_json::from_str(&serde_json::to_string(&session).unwrap())
1211            .expect("existing session format remains restart-compatible");
1212        assert_eq!(restored.selected_skill_ids(), Some(vec!["review".into()]));
1213        assert_eq!(
1214            serde_json::to_value(&restored.messages).unwrap(),
1215            serde_json::to_value(&history).unwrap()
1216        );
1217        assert_eq!(restored.metadata["permission_mode"], "default");
1218        assert_eq!(restored.metadata["skill_mode"], "code");
1219        assert_eq!(restored.metadata["workflow.run_ids.v1"], r#"["run-1"]"#);
1220        assert!(!serde_json::to_string(&restored)
1221            .unwrap()
1222            .contains("old private bytes"));
1223        let event: serde_json::Value =
1224            serde_json::from_str(&restored.metadata[WORKFLOW_ACTIVATION_EVENT_METADATA_KEY])
1225                .unwrap();
1226        assert_eq!(event["type"], "workflow.deactivated");
1227        assert_eq!(event["reason"], "resource_scope_changed");
1228        assert_eq!(event["workflow_id"], "review");
1229    }
1230
1231    #[test]
1232    fn resource_scope_cleanup_removes_pending_or_malformed_legacy_authority() {
1233        for selection in [
1234            serde_json::to_string(&WorkflowSelection {
1235                id: "review".into(),
1236                source: bamboo_skills::WorkflowSource::Builtin,
1237                revision: 7,
1238                args: serde_json::json!({}),
1239            })
1240            .unwrap(),
1241            "malformed".into(),
1242        ] {
1243            let mut session = Session::new("scope-pending", "model");
1244            session
1245                .metadata
1246                .insert(WORKFLOW_SELECTION_METADATA_KEY.into(), selection);
1247            session
1248                .metadata
1249                .insert("skill_runtime_pinned_snapshot_v1".into(), "old pin".into());
1250            clear_workflow_authority_for_resource_scope_change(&mut session);
1251            assert!(!session
1252                .metadata
1253                .contains_key(WORKFLOW_SELECTION_METADATA_KEY));
1254            assert!(!session
1255                .metadata
1256                .contains_key("skill_runtime_pinned_snapshot_v1"));
1257        }
1258    }
1259
1260    #[test]
1261    fn pending_typed_workflow_survives_an_ordinary_chat_before_execute() {
1262        let mut session = Session::new("pending-selection", "model");
1263        let selection = WorkflowSelection {
1264            id: "review".to_string(),
1265            source: bamboo_skills::WorkflowSource::Builtin,
1266            revision: 7,
1267            args: serde_json::json!({}),
1268        };
1269        session.metadata.insert(
1270            WORKFLOW_SELECTION_METADATA_KEY.to_string(),
1271            serde_json::to_string(&selection).expect("selection json"),
1272        );
1273        session.metadata.insert(
1274            bamboo_skills::runtime_metadata::SKILL_RUNTIME_SELECTION_SOURCE_KEY.to_string(),
1275            "explicit".to_string(),
1276        );
1277        session.metadata.insert(
1278            bamboo_skills::runtime_metadata::SKILL_RUNTIME_PINNED_SNAPSHOT_KEY.to_string(),
1279            "opaque durable snapshot".to_string(),
1280        );
1281
1282        resolve_workflow_selection(&mut session, None, None, "one more detail")
1283            .expect("retain pending selection");
1284
1285        assert_eq!(
1286            session.selected_skill_ids(),
1287            Some(vec!["review".to_string()])
1288        );
1289        assert_eq!(
1290            session
1291                .metadata
1292                .get(WORKFLOW_SELECTION_METADATA_KEY)
1293                .and_then(|raw| serde_json::from_str::<WorkflowSelection>(raw).ok()),
1294            Some(selection)
1295        );
1296        assert!(session
1297            .metadata
1298            .contains_key(bamboo_skills::runtime_metadata::SKILL_RUNTIME_PINNED_SNAPSHOT_KEY));
1299    }
1300
1301    #[test]
1302    fn explicit_empty_legacy_selection_deactivates_active_workflow() {
1303        let mut session = Session::new("deactivate-selection", "model");
1304        session.metadata.insert(
1305            ACTIVE_WORKFLOW_METADATA_KEY.to_string(),
1306            serde_json::to_string(&active_workflow("review", 7)).expect("active json"),
1307        );
1308        resolve_workflow_selection(&mut session, None, Some(&[]), "plain message")
1309            .expect("deactivate");
1310        assert!(session.selected_skill_ids().is_none());
1311        assert!(!session.metadata.contains_key(ACTIVE_WORKFLOW_METADATA_KEY));
1312        assert!(session
1313            .metadata
1314            .get(WORKFLOW_ACTIVATION_EVENT_METADATA_KEY)
1315            .is_some_and(|event| event.contains("workflow.deactivated")));
1316    }
1317
1318    // Regression: the request's enhance_prompt must land in the upserted system
1319    // message, not just in session metadata (it was silently dropped once).
1320    #[tokio::test]
1321    async fn prepare_chat_turn_merges_enhance_prompt_into_system_message() {
1322        let session = prepare_chat_turn(
1323            &InMemorySessionAccess,
1324            chat_turn_input(Some("Extra enhancement guidance")),
1325            "",
1326            "Builtin fallback",
1327        )
1328        .await
1329        .expect("prepare_chat_turn should succeed");
1330
1331        let system_prompt = system_message_content(&session);
1332        assert!(system_prompt.starts_with("Base prompt"));
1333        assert!(system_prompt.contains("Extra enhancement guidance"));
1334        assert!(!system_prompt.contains(crate::runtime::context::PROJECT_CONTEXT_START_MARKER));
1335        assert!(!system_prompt.contains(crate::runtime::context::WORKSPACE_CONTEXT_START_MARKER));
1336        assert!(!system_prompt
1337            .contains(crate::runtime::context::instruction::INSTRUCTION_CONTEXT_START_MARKER));
1338        assert!(!system_prompt.contains(crate::runtime::context::ENV_CONTEXT_START_MARKER));
1339        assert!(!system_prompt.contains("BAMBOO_SKILL_CONTEXT_START"));
1340        assert!(!system_prompt.contains("BAMBOO_TOOL_GUIDE_START"));
1341        assert_eq!(
1342            session.enhance_prompt().as_deref(),
1343            Some("Extra enhancement guidance")
1344        );
1345        assert!(session
1346            .metadata
1347            .get(PROMPT_COMPONENT_FLAGS_KEY)
1348            .is_some_and(|flags| flags.contains("enhance=1")));
1349    }
1350
1351    #[tokio::test]
1352    async fn prepare_chat_turn_without_enhance_prompt_keeps_base_only() {
1353        let session = prepare_chat_turn(
1354            &InMemorySessionAccess,
1355            chat_turn_input(None),
1356            "",
1357            "Builtin fallback",
1358        )
1359        .await
1360        .expect("prepare_chat_turn should succeed");
1361
1362        let system_prompt = system_message_content(&session);
1363        assert!(system_prompt.starts_with("Base prompt"));
1364        assert!(session.enhance_prompt().is_none());
1365        assert!(session
1366            .metadata
1367            .get(PROMPT_COMPONENT_FLAGS_KEY)
1368            .is_some_and(|flags| flags.contains("enhance=0")));
1369    }
1370
1371    #[test]
1372    fn root_orchestration_selection_survives_omitted_resume_and_can_be_cleared() {
1373        let mut start = chat_turn_input(None);
1374        start.root_orchestration_prompt = Some(true);
1375        let started =
1376            prepare_chat_turn_from_authoritative_session(None, start, "", "Builtin fallback")
1377                .expect("start opted-in root");
1378        assert!(started.root_orchestration_prompt_enabled());
1379
1380        let reloaded = serde_json::from_str(&serde_json::to_string(&started).unwrap())
1381            .expect("reload persisted session");
1382        let resumed = prepare_chat_turn_from_authoritative_session(
1383            Some(reloaded),
1384            chat_turn_input(None),
1385            "",
1386            "Builtin fallback",
1387        )
1388        .expect("resume without changing selection");
1389        assert!(resumed.root_orchestration_prompt_enabled());
1390
1391        let mut disable = chat_turn_input(None);
1392        disable.root_orchestration_prompt = Some(false);
1393        let disabled = prepare_chat_turn_from_authoritative_session(
1394            Some(resumed),
1395            disable,
1396            "",
1397            "Builtin fallback",
1398        )
1399        .expect("disable root guidance");
1400        assert!(!disabled.root_orchestration_prompt_enabled());
1401    }
1402
1403    #[test]
1404    fn root_tool_selection_persists_on_resume_and_explicit_disable() {
1405        let mut start = chat_turn_input(None);
1406        start.root_orchestration_only = Some(true);
1407        let selected =
1408            prepare_chat_turn_from_authoritative_session(None, start, "", "Builtin fallback")
1409                .expect("select Root tool authority");
1410        assert!(selected.root_orchestration_only_enabled());
1411        assert!(!selected.root_orchestration_prompt_enabled());
1412        assert_eq!(selected.root_tool_authority_revision, 1);
1413
1414        let reloaded: Session = serde_json::from_str(&serde_json::to_string(&selected).unwrap())
1415            .expect("reload durable selection");
1416        let resumed = prepare_chat_turn_from_authoritative_session(
1417            Some(reloaded),
1418            chat_turn_input(None),
1419            "",
1420            "Builtin fallback",
1421        )
1422        .expect("omission preserves selection");
1423        assert!(resumed.root_orchestration_only_enabled());
1424        assert_eq!(resumed.root_tool_authority_revision, 1);
1425
1426        let mut disable = chat_turn_input(None);
1427        disable.root_orchestration_only = Some(false);
1428        let disabled = prepare_chat_turn_from_authoritative_session(
1429            Some(resumed),
1430            disable,
1431            "",
1432            "Builtin fallback",
1433        )
1434        .expect("explicit disable");
1435        assert!(!disabled.root_orchestration_only_enabled());
1436        assert_eq!(disabled.root_tool_authority_revision, 2);
1437
1438        let child = Session::new_child_of("root-child", &disabled, "model", "child");
1439        for enabled in [false, true] {
1440            let mut input = chat_turn_input(None);
1441            input.session_id = child.id.clone();
1442            input.root_orchestration_only = Some(enabled);
1443            assert!(matches!(
1444                prepare_chat_turn_from_authoritative_session(
1445                    Some(child.clone()),
1446                    input,
1447                    "",
1448                    "Builtin fallback"
1449                ),
1450                Err(ChatError::RootToolAuthority(
1451                    bamboo_domain::RootToolAuthorityError::NotRoot
1452                ))
1453            ));
1454        }
1455    }
1456
1457    #[test]
1458    fn root_tool_selection_rejects_workflow_and_plan_before_persistence() {
1459        let mut selected = Session::new("session-enhance", "model");
1460        selected.set_root_orchestration_only(true).unwrap();
1461        let mut skill_request = chat_turn_input(None);
1462        skill_request.selected_skill_ids = Some(vec!["review".into()]);
1463        assert!(matches!(
1464            prepare_chat_turn_from_authoritative_session(
1465                Some(selected.clone()),
1466                skill_request,
1467                "",
1468                "Builtin fallback"
1469            ),
1470            Err(ChatError::RootToolAuthority(
1471                bamboo_domain::RootToolAuthorityError::WorkflowSelected
1472            ))
1473        ));
1474
1475        let selection = WorkflowSelection {
1476            id: "review".into(),
1477            source: bamboo_skills::WorkflowSource::Builtin,
1478            revision: 1,
1479            args: serde_json::json!({}),
1480        };
1481        let mut workflow_request = chat_turn_input(None);
1482        workflow_request.workflow_selection = Some(selection.clone());
1483        assert!(matches!(
1484            prepare_chat_turn_from_authoritative_session(
1485                Some(selected.clone()),
1486                workflow_request,
1487                "",
1488                "Builtin fallback"
1489            ),
1490            Err(ChatError::RootToolAuthority(
1491                bamboo_domain::RootToolAuthorityError::WorkflowSelected
1492            ))
1493        ));
1494
1495        let mut disable_and_select = chat_turn_input(None);
1496        disable_and_select.root_orchestration_only = Some(false);
1497        disable_and_select.workflow_selection = Some(selection);
1498        let switched = prepare_chat_turn_from_authoritative_session(
1499            Some(selected),
1500            disable_and_select,
1501            "",
1502            "Builtin fallback",
1503        )
1504        .expect("explicit disable permits a Workflow selection");
1505        assert!(!switched.root_orchestration_only_enabled());
1506        assert_eq!(switched.selected_skill_ids(), Some(vec!["review".into()]));
1507
1508        let mut malformed = Session::new("session-enhance", "model");
1509        malformed
1510            .metadata
1511            .insert(WORKFLOW_SELECTION_METADATA_KEY.into(), "{".into());
1512        let mut enable = chat_turn_input(None);
1513        enable.root_orchestration_only = Some(true);
1514        assert!(matches!(
1515            prepare_chat_turn_from_authoritative_session(
1516                Some(malformed),
1517                enable,
1518                "",
1519                "Builtin fallback"
1520            ),
1521            Err(ChatError::RootToolAuthority(
1522                bamboo_domain::RootToolAuthorityError::WorkflowSelected
1523            ))
1524        ));
1525
1526        let mut legacy_plan = Session::new("session-enhance", "model");
1527        legacy_plan.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState {
1528            plan_mode: Some(bamboo_domain::PlanModeState {
1529                entered_at: chrono::Utc::now(),
1530                pre_permission_mode: "default".into(),
1531                plan_file_path: None,
1532                status: bamboo_domain::PlanModeStatus::Exploring,
1533            }),
1534            ..bamboo_domain::AgentRuntimeState::default()
1535        });
1536        let mut enable = chat_turn_input(None);
1537        enable.root_orchestration_only = Some(true);
1538        assert!(matches!(
1539            prepare_chat_turn_from_authoritative_session(
1540                Some(legacy_plan),
1541                enable,
1542                "",
1543                "Builtin fallback"
1544            ),
1545            Err(ChatError::RootToolAuthority(
1546                bamboo_domain::RootToolAuthorityError::LegacyPlanActive
1547            ))
1548        ));
1549    }
1550
1551    #[test]
1552    fn supervisor_delegation_first_rejects_execution_workflow_selection_in_both_modes() {
1553        use bamboo_domain::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
1554        for ultra in [false, true] {
1555            let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
1556            supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
1557                incarnation_id: uuid::Uuid::new_v4(),
1558            };
1559            supervisor.set_root_orchestration_only(ultra).unwrap();
1560            let mut input = chat_turn_input(None);
1561            input.session_id = supervisor.id.clone();
1562            let prepared = prepare_chat_turn_from_authoritative_session(
1563                Some(supervisor.clone()),
1564                input,
1565                "",
1566                "base",
1567            )
1568            .unwrap();
1569            assert_eq!(
1570                prepared.root_thinking_mode(),
1571                supervisor.root_thinking_mode()
1572            );
1573            assert!(prepared.model_orchestration_only_enabled());
1574            let mut input = chat_turn_input(None);
1575            input.session_id = supervisor.id.clone();
1576            input.selected_skill_ids = Some(vec!["implementation-workflow".into()]);
1577            assert!(matches!(
1578                prepare_chat_turn_from_authoritative_session(Some(supervisor), input, "", "base"),
1579                Err(ChatError::RootToolAuthority(
1580                    bamboo_domain::RootToolAuthorityError::WorkflowSelected
1581                ))
1582            ));
1583        }
1584    }
1585
1586    #[test]
1587    fn workspace_switch_preserves_system_identity_and_prompt_fingerprint() {
1588        let first_workspace = "/workspace/first-private";
1589        let second_workspace = "/workspace/second-private";
1590        let mut first_input = chat_turn_input(Some("Stable enhancement"));
1591        first_input.workspace_path = Some(first_workspace.to_string());
1592        let first = prepare_chat_turn_from_authoritative_session(
1593            None,
1594            first_input,
1595            "Global prompt",
1596            "Builtin prompt",
1597        )
1598        .expect("first workspace turn");
1599        let first_system = first
1600            .messages
1601            .iter()
1602            .find(|message| matches!(message.role, Role::System))
1603            .expect("first system message")
1604            .clone();
1605        let first_fingerprint = first
1606            .metadata
1607            .get(PROMPT_FINGERPRINT_KEY)
1608            .expect("first prompt fingerprint")
1609            .clone();
1610
1611        let mut switched_input = chat_turn_input(Some("Stable enhancement"));
1612        switched_input.workspace_path = Some(second_workspace.to_string());
1613        let switched = prepare_chat_turn_from_authoritative_session(
1614            Some(first),
1615            switched_input,
1616            "Global prompt",
1617            "Builtin prompt",
1618        )
1619        .expect("switched workspace turn");
1620        let switched_system = switched
1621            .messages
1622            .iter()
1623            .find(|message| matches!(message.role, Role::System))
1624            .expect("switched system message");
1625
1626        assert_eq!(switched_system.id, first_system.id);
1627        assert_eq!(switched_system.content, first_system.content);
1628        assert_eq!(
1629            switched.metadata.get(PROMPT_FINGERPRINT_KEY),
1630            Some(&first_fingerprint)
1631        );
1632        assert_eq!(
1633            switched.workspace_path_meta().as_deref(),
1634            Some(second_workspace)
1635        );
1636        assert!(!switched_system.content.contains(first_workspace));
1637        assert!(!switched_system.content.contains(second_workspace));
1638        assert!(!switched_system
1639            .content
1640            .contains(crate::runtime::context::WORKSPACE_CONTEXT_START_MARKER));
1641        assert!(switched
1642            .prompt_snapshot
1643            .as_ref()
1644            .and_then(|snapshot| snapshot.workspace_context.as_deref())
1645            .is_some_and(|context| context.contains(second_workspace)));
1646    }
1647
1648    #[test]
1649    fn authoritative_none_uses_session_fallback_during_prompt_composition() {
1650        let fixture = tempfile::tempdir().expect("fixture");
1651        let foreign_default = fixture.path().join("foreign-default");
1652        std::fs::create_dir_all(&foreign_default).expect("foreign default");
1653        std::fs::write(
1654            fixture.path().join("config.json"),
1655            serde_json::json!({
1656                "default_work_area": { "path": foreign_default.to_string_lossy() }
1657            })
1658            .to_string(),
1659        )
1660        .expect("write legacy config");
1661        let session_fallback = fixture
1662            .path()
1663            .join("request-state-root/workspaces/session-enhance");
1664        let session_fallback_display = path_to_display_string(&session_fallback);
1665        let mut input = chat_turn_input(None);
1666        input.data_dir = Some(fixture.path().to_path_buf());
1667
1668        let session = prepare_chat_turn_from_authoritative_session_with_workspace_policy(
1669            None,
1670            input,
1671            "global",
1672            "builtin",
1673            ChatWorkspaceFallbackPolicy::Authoritative {
1674                session_fallback_path: Some(session_fallback_display.clone()),
1675            },
1676        )
1677        .expect("authoritative turn");
1678
1679        assert_eq!(
1680            session.workspace_path_meta().as_deref(),
1681            Some(session_fallback_display.as_str())
1682        );
1683        let system_prompt = system_message_content(&session);
1684        assert!(
1685            !system_prompt.contains(&session_fallback_display),
1686            "workspace path must stay out of the persisted System message"
1687        );
1688        assert!(
1689            !system_prompt.contains(foreign_default.to_string_lossy().as_ref()),
1690            "authoritative None must suppress the legacy configured default"
1691        );
1692        assert!(
1693            session
1694                .prompt_snapshot
1695                .as_ref()
1696                .and_then(|snapshot| snapshot.workspace_context.as_deref())
1697                .is_some_and(|context| context.contains(&session_fallback_display)),
1698            "prompt snapshot must retain the authoritative session fallback"
1699        );
1700    }
1701
1702    #[test]
1703    fn authoritative_none_without_session_fallback_skips_legacy_lookup() {
1704        let resolved = resolve_workspace_fallback_with(
1705            &ChatWorkspaceFallbackPolicy::Authoritative {
1706                session_fallback_path: None,
1707            },
1708            || panic!("authoritative None must not read a process-global or disk default"),
1709        );
1710
1711        assert_eq!(resolved, None);
1712    }
1713
1714    #[test]
1715    fn legacy_policy_preserves_non_server_default_workspace_lookup() {
1716        let fixture = tempfile::tempdir().expect("fixture");
1717        let configured_default = fixture.path().join("configured-default");
1718        std::fs::create_dir_all(&configured_default).expect("configured default");
1719        std::fs::write(
1720            fixture.path().join("config.json"),
1721            serde_json::json!({
1722                "default_work_area": { "path": configured_default.to_string_lossy() }
1723            })
1724            .to_string(),
1725        )
1726        .expect("write config");
1727        let mut input = chat_turn_input(None);
1728        input.data_dir = Some(fixture.path().to_path_buf());
1729
1730        let session =
1731            prepare_chat_turn_from_authoritative_session(None, input, "global", "builtin")
1732                .expect("legacy turn");
1733        let resolved = session
1734            .workspace_path_meta()
1735            .map(PathBuf::from)
1736            .expect("legacy configured default");
1737
1738        assert_eq!(
1739            resolved.canonicalize().expect("resolved canonical"),
1740            configured_default
1741                .canonicalize()
1742                .expect("configured canonical")
1743        );
1744        assert_eq!(
1745            session
1746                .metadata
1747                .get(crate::project_context::WORKSPACE_SOURCE_METADATA_KEY)
1748                .map(String::as_str),
1749            Some(crate::project_context::WorkspaceSource::Session.as_str())
1750        );
1751        assert!(session
1752            .prompt_snapshot
1753            .as_ref()
1754            .and_then(|snapshot| snapshot.workspace_context.as_deref())
1755            .is_some_and(|context| context.contains("Workspace source: session")));
1756    }
1757
1758    // The non-server disk fallback (`default_workspace_from_data_dir`) tested
1759    // directly + deterministically — no global workspace-provider involved (the
1760    // server-side, provider-gated path can't be unit-tested due to the
1761    // first-wins OnceLock). #38 / #131.
1762
1763    #[test]
1764    fn default_workspace_from_data_dir_reads_configured_work_area() {
1765        let temp = tempfile::tempdir().expect("temp dir");
1766        let workspace = temp.path().join("default-workspace");
1767        std::fs::create_dir_all(&workspace).expect("workspace dir");
1768        std::fs::write(
1769            temp.path().join("config.json"),
1770            serde_json::json!({
1771                "default_work_area": { "path": workspace.to_string_lossy() }
1772            })
1773            .to_string(),
1774        )
1775        .expect("write config.json");
1776
1777        let resolved = default_workspace_from_data_dir(Some(temp.path())).expect("resolves");
1778        // get_default_work_area_path returns the non-canonical candidate, and temp
1779        // dirs live under a symlinked prefix on macOS (/var -> /private/var), so
1780        // canonicalize BOTH sides before comparing.
1781        assert_eq!(
1782            resolved.canonicalize().unwrap(),
1783            workspace.canonicalize().unwrap()
1784        );
1785    }
1786
1787    #[test]
1788    fn default_workspace_from_data_dir_is_none_without_config() {
1789        let temp = tempfile::tempdir().expect("temp dir");
1790        assert!(default_workspace_from_data_dir(Some(temp.path())).is_none());
1791    }
1792}