From c7309f092b6e03e6dc067c1ac24e462f9c19771c Mon Sep 17 00:00:00 2001 From: Ben Gao Date: Wed, 3 Jun 2026 21:20:41 +0800 Subject: [PATCH 1/3] feat(api): add POST /v1/sessions endpoint to save thread as session Add a new API endpoint that allows saving an existing thread as a session for cross-workspace resumption. This enables GUI clients to persist conversation state so that sessions can be resumed from any workspace. The endpoint extracts messages from thread turns, calculates total tokens, and creates a session file using the existing session manager. It supports optional custom title, falling back to the thread title or the first user message. POST /v1/sessions Request: { thread_id: string, title?: string } Response: { session_id, thread_id, message_count, title } --- crates/tui/src/runtime_api.rs | 148 +++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 8602f5746..52986c537 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -41,6 +41,7 @@ use crate::runtime_threads::{ ThreadDetail, ThreadListFilter, ThreadRecord, TurnItemKind, TurnRecord, UpdateThreadRequest, UsageGroupBy, }; +use crate::models::{ContentBlock, Message}; use crate::session_manager::{SavedSession, SessionManager, SessionMetadata, default_sessions_dir}; use crate::skill_state::SkillStateStore; use crate::task_manager::{ @@ -514,7 +515,7 @@ 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 +858,151 @@ 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| ApiError::not_found(format!("Thread {} not found: {}", req.thread_id, e)))?; + + let thread = detail.thread; + + // Extract messages from items (UserMessage and AgentMessage items) + let mut messages: Vec = Vec::new(); + + // Group items by turn, then extract user/agent messages + for turn in &detail.turns { + let turn_items: Vec<_> = detail + .items + .iter() + .filter(|item| item.turn_id == turn.id) + .collect(); + + // Find user message item + let user_item = turn_items.iter().find(|item| item.kind == TurnItemKind::UserMessage); + // Find agent message item + let agent_item = turn_items.iter().find(|item| item.kind == TurnItemKind::AgentMessage); + + // Create user message if present + if let Some(user) = user_item { + let text = user.detail.clone().unwrap_or_else(|| user.summary.clone()); + if !text.trim().is_empty() { + messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { text, cache_control: None }], + }); + } + } + + // Create assistant message if present + if let Some(agent) = agent_item { + let text = agent.detail.clone().unwrap_or_else(|| agent.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, .. } => { + let truncated = if text.len() > 50 { + text.chars().take(50).collect::() + "..." + } else { + text.clone() + }; + Some(truncated) + } + _ => 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 From 5283978c00a4298bd97fac7c899594fad390b0ee Mon Sep 17 00:00:00 2001 From: Ben Gao Date: Wed, 3 Jun 2026 23:29:48 +0800 Subject: [PATCH 2/3] fix(api): address PR review feedback for POST /v1/sessions 1. Iterate directly over detail.items instead of grouping by turn and using find(), which silently dropped multiple messages in steered turns. 2. Use the existing truncate_text() helper instead of manual byte-length truncation (text.len() > 50), which incorrectly compared UTF-8 byte count against a character limit. 3. Distinguish between "not found" and internal errors when get_thread_detail fails, returning 404 only for missing threads and 500 for storage/I/O errors. --- crates/tui/src/runtime_api.rs | 71 +++++++++++++++-------------------- 1 file changed, 31 insertions(+), 40 deletions(-) diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 52986c537..3e940be42 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -887,46 +887,44 @@ async fn create_session_from_thread( .runtime_threads .get_thread_detail(&req.thread_id) .await - .map_err(|e| ApiError::not_found(format!("Thread {} not found: {}", req.thread_id, e)))?; + .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(); - // Group items by turn, then extract user/agent messages - for turn in &detail.turns { - let turn_items: Vec<_> = detail - .items - .iter() - .filter(|item| item.turn_id == turn.id) - .collect(); - - // Find user message item - let user_item = turn_items.iter().find(|item| item.kind == TurnItemKind::UserMessage); - // Find agent message item - let agent_item = turn_items.iter().find(|item| item.kind == TurnItemKind::AgentMessage); - - // Create user message if present - if let Some(user) = user_item { - let text = user.detail.clone().unwrap_or_else(|| user.summary.clone()); - if !text.trim().is_empty() { - messages.push(Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { text, cache_control: None }], - }); + 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 }], + }); + } } - } - - // Create assistant message if present - if let Some(agent) = agent_item { - let text = agent.detail.clone().unwrap_or_else(|| agent.summary.clone()); - if !text.trim().is_empty() { - messages.push(Message { - role: "assistant".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 }], + }); + } } + _ => {} } } @@ -950,14 +948,7 @@ async fn create_session_from_thread( .find(|m| m.role == "user") .and_then(|m| { m.content.iter().find_map(|block| match block { - ContentBlock::Text { text, .. } => { - let truncated = if text.len() > 50 { - text.chars().take(50).collect::() + "..." - } else { - text.clone() - }; - Some(truncated) - } + ContentBlock::Text { text, .. } => Some(truncate_text(text, 50)), _ => None, }) }) From bf8f81873a896bae5b322db905981bca825b6e15 Mon Sep 17 00:00:00 2001 From: Ben Gao Date: Thu, 4 Jun 2026 00:23:12 +0800 Subject: [PATCH 3/3] style: apply cargo fmt formatting fixes --- crates/tui/src/runtime_api.rs | 51 ++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 3e940be42..7cc3820bf 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -35,13 +35,13 @@ 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, ThreadDetail, ThreadListFilter, ThreadRecord, TurnItemKind, TurnRecord, UpdateThreadRequest, UsageGroupBy, }; -use crate::models::{ContentBlock, Message}; use crate::session_manager::{SavedSession, SessionManager, SessionMetadata, default_sessions_dir}; use crate::skill_state::SkillStateStore; use crate::task_manager::{ @@ -515,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).post(create_session_from_thread)) + .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", @@ -911,7 +914,10 @@ async fn create_session_from_thread( if !text.trim().is_empty() { messages.push(Message { role: "user".to_string(), - content: vec![ContentBlock::Text { text, cache_control: None }], + content: vec![ContentBlock::Text { + text, + cache_control: None, + }], }); } } @@ -920,7 +926,10 @@ async fn create_session_from_thread( if !text.trim().is_empty() { messages.push(Message { role: "assistant".to_string(), - content: vec![ContentBlock::Text { text, cache_control: None }], + content: vec![ContentBlock::Text { + text, + cache_control: None, + }], }); } } @@ -938,22 +947,19 @@ async fn create_session_from_thread( // 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, - }) + 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()) - }) + }) + .unwrap_or_else(|| "Saved Session".to_string()) + }) }); // Create session using session_manager helper @@ -962,8 +968,11 @@ async fn create_session_from_thread( 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 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,