feat(api): add POST /v1/sessions endpoint to save thread as session#2639
feat(api): add POST /v1/sessions endpoint to save thread as session#2639gaord wants to merge 3 commits into
Conversation
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 }
|
Thanks @gaord for taking the time to contribute. This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this pull request is staying open. When enforcement is enabled, pull requests from contributors who are not listed in Please read |
There was a problem hiding this comment.
Code Review
This pull request introduces a new POST endpoint /v1/sessions to create a saved session from an existing thread. The reviewer provided valuable feedback on improving the message extraction logic by iterating directly over detail.items to avoid dropping multiple messages in a single turn. Additionally, they recommended using the existing truncate_text helper function to handle title truncation safely and concisely.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| // Extract messages from items (UserMessage and AgentMessage items) | ||
| let mut messages: Vec<Message> = 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 }], | ||
| }); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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 }],
});
}
}
_ => {}
}
}| let truncated = if text.len() > 50 { | ||
| text.chars().take(50).collect::<String>() + "..." | ||
| } else { | ||
| text.clone() | ||
| }; |
There was a problem hiding this comment.
| // 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, | ||
| }), | ||
| )) |
There was a problem hiding this comment.
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.
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.
Summary
Add a new
POST /v1/sessionsAPI endpoint that allows saving an existing thread as a session for cross-workspace resumption.Motivation
When using the GUI (VSCode extension), conversations are managed as threads. However, sessions are the primary mechanism for persisting and resuming conversations across workspaces in the TUI. This API bridges the gap by allowing the GUI to save thread state as a session, enabling:
Changes
POST /v1/sessionsroute alongside existingGET /v1/sessionsCreateSessionRequeststruct withthread_idand optionaltitleCreateSessionResponsestruct withsession_id,thread_id,message_count,titlecreate_session_from_thread()handler that:SessionManagerAPI Contract
Testing
GET /v1/sessionsPOST /v1/sessions/{id}/resumeworks correctlyGreptile Summary
Adds
POST /v1/sessionsto the existing/v1/sessionsroute so GUI clients can persist a thread as a resumable session. The handler fetches thread details, convertsTurnItemKind::UserMessageandAgentMessageitems intoMessageobjects, sums token usage, derives a title, and delegates to the existingSessionManager.CreateSessionRequest/CreateSessionResponsestructs; route wiring alongside the existingGET /v1/sessions.get_thread_detaildistinguishes 404 from 500 via substring matching on the error string, which is fragile if error messages change.Confidence Score: 3/5
Not safe to merge as-is: calling the endpoint during an active turn writes a broken session (partial or unanswered agent message) that will produce confusing behavior on resume.
The handler snapshots thread state without first verifying that no turn is currently running. A turn in flight writes its AgentMessage detail incrementally; if the endpoint is called mid-stream the partial text is captured verbatim, and if called before any content arrives the assistant message is silently dropped, leaving an unanswered user message as the last entry. Either case produces a session whose conversation history is structurally invalid for resumption.
crates/tui/src/runtime_api.rs — specifically the create_session_from_thread handler, which needs an in-progress turn guard before message extraction.
Important Files Changed
Sequence Diagram
sequenceDiagram participant Client participant API as POST /v1/sessions participant RTM as RuntimeThreadManager participant SM as SessionManager Client->>API: "POST /v1/sessions {thread_id, title?}" API->>RTM: get_thread_detail(thread_id) RTM-->>API: ThreadDetail (thread, turns, items) Note over API: Iterate items for UserMessage/AgentMessage Note over API: Sum input+output tokens across turns Note over API: Determine title (req.title → thread.title → first user msg) API->>SM: SessionManager::new(sessions_dir) SM-->>API: manager API->>API: create_saved_session_with_id_and_mode(...) API->>SM: "save_session(&session)" SM->>SM: "cleanup_old_sessions() [MAX=50]" SM-->>API: Ok(path) API-->>Client: "201 Created {session_id, thread_id, message_count, title}"Reviews (2): Last reviewed commit: "style: apply cargo fmt formatting fixes" | Re-trigger Greptile