use actix_web::{web, HttpResponse, Responder};
use super::{ChatRequest, ChatResponse};
use crate::app_state::AppState;
use bamboo_engine::model_config_helper::{
parse_session_gold_config, resolve_gold_config, GOLD_CONFIG_METADATA_KEY,
};
use crate::session_app::chat::{parse_goal_command, GoalCommand};
use crate::session_app::metadata::SessionMetadataService;
use bamboo_engine::config::GoldConfig;
mod images;
mod request;
fn sync_runtime_workspace(session_id: &str, workspace_path: Option<&str>) {
let preferred = workspace_path
.map(str::trim)
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
.and_then(|path| std::fs::canonicalize(&path).ok().or(Some(path)))
.filter(|path| path.is_dir());
let _ = bamboo_tools::tools::workspace_state::ensure_session_workspace(session_id, preferred);
}
#[cfg(test)]
mod tests;
pub async fn handler(state: web::Data<AppState>, req: web::Json<ChatRequest>) -> impl Responder {
let session_id = request::resolve_session_id(req.session_id.as_deref());
tracing::debug!(
"[{}] Chat requested: message_len={}, is_goal_command={}, image_count={}",
session_id,
req.message.len(),
parse_goal_command(&req.message).is_some(),
req.images.as_ref().map(|i| i.len()).unwrap_or(0),
);
let model = match request::validate_and_normalize_model(req.model.as_str()) {
Ok(model) => model,
Err(response) => return response,
};
let global_default_prompt =
bamboo_engine::prompt_defaults::read_global_default_system_prompt_template();
let builtin_fallback_prompt = crate::app_state::DEFAULT_BASE_PROMPT;
let workspace_path = request::optional_non_empty(req.workspace_path.as_deref());
let data_dir = Some(state.app_data_dir.clone());
sync_runtime_workspace(
&session_id,
workspace_path.map(str::trim).filter(|s| !s.is_empty()),
);
let input = crate::session_app::types::ChatTurnInput {
session_id: session_id.clone(),
model: model.clone(),
model_ref: req.model_ref.clone(),
provider: req.provider.clone(),
message: req.message.clone(),
system_prompt: request::optional_non_empty(req.system_prompt.as_deref()).map(String::from),
enhance_prompt: request::optional_non_empty(req.enhance_prompt.as_deref())
.map(String::from),
workspace_path: workspace_path.map(String::from),
selected_skill_ids: req.selected_skill_ids.clone(),
copilot_conclusion_with_options_enhancement_enabled: req
.copilot_conclusion_with_options_enhancement_enabled,
data_dir,
};
let mut session = match crate::session_app::chat::prepare_chat_turn(
state.as_ref(),
input,
global_default_prompt.as_str(),
builtin_fallback_prompt,
)
.await
{
Ok(session) => session,
Err(error) => {
tracing::error!("Chat turn preparation failed: {error}");
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to prepare chat: {error}")
}));
}
};
if let Some(goal_cmd) = parse_goal_command(&req.message) {
tracing::debug!(
"[{}] Chat intercepted as /goal command: {:?}",
session_id,
goal_cmd
);
return handle_goal_command(state.as_ref(), &session_id, &goal_cmd).await;
}
if let Err(response) =
images::append_user_message(&state, &mut session, &req.message, req.images.as_deref()).await
{
return response;
}
state.save_and_cache_session(&mut session).await;
if let Some(msg) = session.messages.last() {
state.account_sink.record(
Some(&session_id),
&bamboo_agent_core::AgentEvent::MessageAppended {
session_id: session_id.clone(),
message_id: msg.id.clone(),
role: msg.role.clone(),
content: msg.content.clone(),
created_at: msg.created_at,
},
);
}
tracing::debug!(
"[{}] Chat turn persisted: messages={}, last_role={:?} -> client should now POST /execute",
session_id,
session.messages.len(),
session.messages.last().map(|m| format!("{:?}", m.role)),
);
HttpResponse::Created().json(ChatResponse {
session_id: session_id.clone(),
stream_url: format!("/api/v1/events/{}", session_id),
status: "streaming".to_string(),
goal_command: None,
})
}
#[derive(Debug, serde::Serialize)]
pub struct GoalCommandResponse {
pub action: String,
pub should_execute: bool,
pub gold_config: Option<GoldConfig>,
}
async fn handle_goal_command(
state: &AppState,
session_id: &str,
cmd: &GoalCommand,
) -> HttpResponse {
let config_snapshot = state.config.read().await.clone();
let session = match state.load_session_merged(session_id).await {
Some(s) => s,
None => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": "Session not found",
"session_id": session_id
}));
}
};
let current_json = session.metadata.get(GOLD_CONFIG_METADATA_KEY).cloned();
let current_effective = resolve_gold_config(&config_snapshot, current_json.as_deref());
let (new_config, should_resume) = match cmd {
GoalCommand::Status => {
let response_config = current_effective.clone();
return HttpResponse::Ok().json(ChatResponse {
session_id: session_id.to_string(),
stream_url: format!("/api/v1/events/{}", session_id),
status: "accepted".to_string(),
goal_command: Some(GoalCommandResponse {
action: "status".to_string(),
should_execute: false,
gold_config: response_config,
}),
});
}
GoalCommand::Off => {
let mut cfg = current_effective.unwrap_or_default();
cfg.enabled = false;
cfg.auto_answer_enabled = false;
cfg.auto_continue_enabled = false;
(cfg, false)
}
GoalCommand::Clear => {
let mut cfg = current_effective.unwrap_or_default();
cfg.enabled = false;
cfg.auto_answer_enabled = false;
cfg.auto_continue_enabled = false;
cfg.goal = None;
cfg.evaluation_prompt = None;
(cfg, false)
}
GoalCommand::On => {
let mut cfg = current_effective.unwrap_or_default();
let has_prompt = cfg.effective_goal().is_some();
if !has_prompt {
return HttpResponse::Ok().json(ChatResponse {
session_id: session_id.to_string(),
stream_url: format!("/api/v1/events/{}", session_id),
status: "accepted".to_string(),
goal_command: Some(GoalCommandResponse {
action: "on_no_prompt".to_string(),
should_execute: false,
gold_config: Some(cfg),
}),
});
}
cfg.enabled = true;
cfg.auto_answer_enabled = true;
cfg.auto_continue_enabled = true;
(cfg, false)
}
GoalCommand::SetPrompt(prompt) => {
let mut cfg = current_effective.unwrap_or_default();
cfg.enabled = true;
cfg.auto_answer_enabled = true;
cfg.auto_continue_enabled = true;
cfg.goal = Some(prompt.clone());
(cfg, true)
}
};
let new_json = serde_json::to_string(&new_config).ok();
match SessionMetadataService::set_gold_config_json(state, session_id, new_json.clone(), None)
.await
{
Ok(_) => {}
Err(e) => {
tracing::error!(session_id = %session_id, "Failed to persist gold_config: {e}");
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to update goal config: {e}")
}));
}
}
if should_resume {
if let Some(mut session) = state.load_session_merged(session_id).await {
session.metadata.remove("gold.auto_continue_count");
session.metadata.remove("gold.last_continue_requested_at");
session.metadata.remove("gold.last_evaluation");
session.metadata.remove("gold.last_decision");
session.metadata.remove("gold.last_confidence");
if let Some(runtime_state) = session.agent_runtime_state.as_mut() {
runtime_state.status = bamboo_domain::AgentStatusState::Idle;
runtime_state.suspension = None;
runtime_state.waiting_for_children = None;
}
session.metadata.remove("runtime.suspend_reason");
let goal_text = parse_session_gold_config(new_json.as_deref())
.as_ref()
.and_then(|cfg| cfg.effective_goal().map(str::to_string))
.unwrap_or_default();
let instruction = format!(
"The user has set a session goal:\n\n{goal_text}\n\nBefore taking any action, briefly confirm your understanding of this goal, surface any ambiguities or assumptions, and outline how you plan to achieve it. If anything is genuinely unclear or you need a decision from the user, ask them now. Otherwise, state your plan and begin working toward the goal."
);
let mut resume_msg = bamboo_domain::Message::user(instruction);
resume_msg.metadata = Some(serde_json::json!({
"hidden_from_ui": true,
"runtime_kind": "gold_goal_resume"
}));
session.add_message(resume_msg);
state.save_and_cache_session(&mut session).await;
}
}
let response_config = parse_session_gold_config(new_json.as_deref());
HttpResponse::Ok().json(ChatResponse {
session_id: session_id.to_string(),
stream_url: format!("/api/v1/events/{}", session_id),
status: "accepted".to_string(),
goal_command: Some(GoalCommandResponse {
action: match cmd {
GoalCommand::Off => "off".to_string(),
GoalCommand::Clear => "clear".to_string(),
GoalCommand::On => "on".to_string(),
GoalCommand::SetPrompt(_) => "set_prompt".to_string(),
GoalCommand::Status => unreachable!(),
},
should_execute: should_resume,
gold_config: response_config,
}),
})
}