Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 147 additions & 1 deletion crates/tui/src/runtime_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
}],
});
}
}
_ => {}
}
}
Comment on lines +905 to +938
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

By grouping items by turn and using .find() to extract only the first UserMessage and AgentMessage (lines 906-908), any steered messages or multiple messages within a single turn will be silently dropped.

Since detail.items is already populated chronologically by turn (as seen in get_thread_detail), you can simplify this entire block and preserve all messages by iterating directly over detail.items.

    // Extract messages from items (UserMessage and AgentMessage items)
    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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No idempotency — repeated POSTs silently create duplicate sessions

Each call to POST /v1/sessions generates a fresh uuid::Uuid::new_v4(), so calling it twice for the same thread_id (e.g., from an "auto-save on turn completion" client) creates two distinct sessions. Because save_session enforces a MAX_SESSIONS = 50 cap and evicts the oldest entries on every write, aggressive auto-save callers can quickly push earlier sessions out of the store. Consider returning an existing session when a session for the given thread_id already exists, or accepting a caller-provided idempotency key.

Fix in Codex Fix in Claude Code Fix in Cursor

}

fn session_to_detail(session: SavedSession) -> SessionDetailResponse {
let messages: Vec<serde_json::Value> = session
.messages
Expand Down
Loading