diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 8602f5746..7cc3820bf 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -35,6 +35,7 @@ use crate::automation_manager::{ }; use crate::config::{Config, DEFAULT_TEXT_MODEL}; use crate::mcp::{McpConfig, McpPool}; +use crate::models::{ContentBlock, Message}; use crate::runtime_threads::{ CompactThreadRequest, CreateThreadRequest, ExternalApprovalDecision, RuntimeThreadManager, RuntimeThreadManagerConfig, SharedRuntimeThreadManager, StartTurnRequest, SteerTurnRequest, @@ -514,7 +515,10 @@ pub async fn run_http_server( pub fn build_router(state: RuntimeApiState) -> Router { let api_routes = Router::new() - .route("/v1/sessions", get(list_sessions)) + .route( + "/v1/sessions", + get(list_sessions).post(create_session_from_thread), + ) .route("/v1/sessions/{id}", get(get_session).delete(delete_session)) .route( "/v1/sessions/{id}/resume-thread", @@ -857,6 +861,148 @@ async fn delete_session( Ok(StatusCode::NO_CONTENT) } +/// Request body for creating a session from a thread +#[derive(Debug, Deserialize)] +struct CreateSessionRequest { + /// Thread ID to save as session + thread_id: String, + /// Optional custom title (defaults to thread title or first message) + #[serde(default)] + title: Option, +} + +/// Response for session creation +#[derive(Debug, Serialize)] +struct CreateSessionResponse { + session_id: String, + thread_id: String, + message_count: usize, + title: String, +} + +/// Save a thread as a session for cross-workspace resumption +async fn create_session_from_thread( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), ApiError> { + // Get thread detail + let detail = state + .runtime_threads + .get_thread_detail(&req.thread_id) + .await + .map_err(|e| { + // Distinguish between "not found" and internal errors + let msg = e.to_string(); + if msg.contains("not found") || msg.contains("no rows") { + ApiError::not_found(format!("Thread {} not found", req.thread_id)) + } else { + ApiError::internal(format!("Failed to get thread {}: {}", req.thread_id, e)) + } + })?; + + let thread = detail.thread; + + // Extract messages from items (UserMessage and AgentMessage items) + // Iterate directly over items to preserve all messages, including + // multiple user/agent messages within a single turn (e.g. steered turns) + let mut messages: Vec = Vec::new(); + + for item in &detail.items { + match item.kind { + TurnItemKind::UserMessage => { + let text = item.detail.clone().unwrap_or_else(|| item.summary.clone()); + if !text.trim().is_empty() { + messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text, + cache_control: None, + }], + }); + } + } + TurnItemKind::AgentMessage => { + let text = item.detail.clone().unwrap_or_else(|| item.summary.clone()); + if !text.trim().is_empty() { + messages.push(Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text, + cache_control: None, + }], + }); + } + } + _ => {} + } + } + + // Calculate total tokens from turns + let total_tokens: u64 = detail + .turns + .iter() + .filter_map(|t| t.usage.as_ref()) + .map(|u| u.input_tokens as u64 + u.output_tokens as u64) + .sum(); + + // Determine title + let title = req.title.clone().unwrap_or_else(|| { + thread.title.clone().unwrap_or_else(|| { + // Fallback: first user message + messages + .iter() + .find(|m| m.role == "user") + .and_then(|m| { + m.content.iter().find_map(|block| match block { + ContentBlock::Text { text, .. } => Some(truncate_text(text, 50)), + _ => None, + }) + }) + .unwrap_or_else(|| "Saved Session".to_string()) + }) + }); + + // Create session using session_manager helper + let manager = SessionManager::new(state.sessions_dir.clone()) + .map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?; + + let session_id = uuid::Uuid::new_v4().to_string(); + // Convert thread system_prompt (String) to SystemPrompt enum + let system_prompt = thread + .system_prompt + .as_ref() + .map(|s| crate::models::SystemPrompt::Text(s.clone())); + + let session = crate::session_manager::create_saved_session_with_id_and_mode( + session_id.clone(), + &messages, + &thread.model, + &thread.workspace, + total_tokens, + system_prompt.as_ref(), + Some(&thread.mode), + ); + + // Override title if provided + let mut session = session; + session.metadata.title = title.clone(); + + // Save session + manager + .save_session(&session) + .map_err(|e| ApiError::internal(format!("Failed to save session: {e}")))?; + + Ok(( + StatusCode::CREATED, + Json(CreateSessionResponse { + session_id, + thread_id: thread.id, + message_count: messages.len(), + title, + }), + )) +} + fn session_to_detail(session: SavedSession) -> SessionDetailResponse { let messages: Vec = session .messages