-
Notifications
You must be signed in to change notification settings - Fork 3.2k
feat(api): add POST /v1/sessions endpoint to save thread as session #2639
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<String>, | ||
| } | ||
|
|
||
| /// 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<RuntimeApiState>, | ||
| Json(req): Json<CreateSessionRequest>, | ||
| ) -> Result<(StatusCode, Json<CreateSessionResponse>), 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<Message> = 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, | ||
| }), | ||
| )) | ||
|
Comment on lines
+965
to
+1003
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Each call to |
||
| } | ||
|
|
||
| fn session_to_detail(session: SavedSession) -> SessionDetailResponse { | ||
| let messages: Vec<serde_json::Value> = session | ||
| .messages | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By grouping items by turn and using
.find()to extract only the firstUserMessageandAgentMessage(lines 906-908), any steered messages or multiple messages within a single turn will be silently dropped.Since
detail.itemsis already populated chronologically by turn (as seen inget_thread_detail), you can simplify this entire block and preserve all messages by iterating directly overdetail.items.