Skip to content

feat(api): add POST /v1/sessions endpoint to save thread as session#2639

Open
gaord wants to merge 3 commits into
Hmbown:mainfrom
gaord:feat/session-save-api
Open

feat(api): add POST /v1/sessions endpoint to save thread as session#2639
gaord wants to merge 3 commits into
Hmbown:mainfrom
gaord:feat/session-save-api

Conversation

@gaord
Copy link
Copy Markdown
Contributor

@gaord gaord commented Jun 3, 2026

Summary

Add a new POST /v1/sessions API 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:

  1. Cross-workspace resumption: Sessions saved from one workspace can be resumed in another
  2. GUI-TUI consistency: Both interfaces share the same session storage format
  3. Automatic persistence: GUI clients can auto-save threads as sessions on turn completion

Changes

  • Add POST /v1/sessions route alongside existing GET /v1/sessions
  • Add CreateSessionRequest struct with thread_id and optional title
  • Add CreateSessionResponse struct with session_id, thread_id, message_count, title
  • Implement create_session_from_thread() handler that:
    • Extracts messages from thread turns (user and agent messages)
    • Calculates total tokens from turn usage
    • Creates a session using the existing SessionManager
    • Supports optional custom title (falls back to thread title or first user message)

API Contract

POST /v1/sessions
Content-Type: application/json

Request:
{
  "thread_id": "uuid-of-thread",
  "title": "Optional custom title"
}

Response (201 Created):
{
  "session_id": "newly-generated-uuid",
  "thread_id": "original-thread-uuid",
  "message_count": 6,
  "title": "My Conversation"
}

Testing

  • Manually tested with GUI client: threads are correctly saved as sessions
  • Session files are created in sessions directory and can be listed via GET /v1/sessions
  • Resuming saved sessions via POST /v1/sessions/{id}/resume works correctly

Greptile Summary

Adds POST /v1/sessions to the existing /v1/sessions route so GUI clients can persist a thread as a resumable session. The handler fetches thread details, converts TurnItemKind::UserMessage and AgentMessage items into Message objects, sums token usage, derives a title, and delegates to the existing SessionManager.

  • New CreateSessionRequest / CreateSessionResponse structs; route wiring alongside the existing GET /v1/sessions.
  • Message extraction correctly iterates all items (including steered turns) and skips empty text, but there is no guard against calling the endpoint while a turn is still active, which can produce an inconsistent session.
  • Error handling for get_thread_detail distinguishes 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

Filename Overview
crates/tui/src/runtime_api.rs Adds POST /v1/sessions handler that snapshots a thread as a session; missing guard for in-progress turns means saving during an active turn produces an inconsistent session (partial or missing agent message).

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}"
Loading

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (2): Last reviewed commit: "style: apply cargo fmt formatting fixes" | Re-trigger Greptile

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 }
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 3, 2026

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 .github/APPROVED_CONTRIBUTORS will be closed automatically.

Please read CONTRIBUTING.md for the expected contribution shape. A maintainer can grant PR access by commenting /lgtm on a pull request.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +894 to +931
// 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 }],
});
}
}
}
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 }],
                    });
                }
            }
            _ => {}
        }
    }

Comment thread crates/tui/src/runtime_api.rs Outdated
Comment on lines +954 to +958
let truncated = if text.len() > 50 {
text.chars().take(50).collect::<String>() + "..."
} else {
text.clone()
};
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.

medium

Use the existing truncate_text helper function instead of manually reimplementing the truncation logic. This also correctly handles multi-byte Unicode characters since truncate_text operates on character counts rather than byte lengths.

                                let truncated = truncate_text(text, 50);

Comment thread crates/tui/src/runtime_api.rs Outdated
Comment thread crates/tui/src/runtime_api.rs Outdated
Comment on lines +968 to +1003
// 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,
}),
))
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

Comment thread crates/tui/src/runtime_api.rs Outdated
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.
@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented Jun 3, 2026

Thanks @gaord for the sessions endpoint PR. I am keeping API-surface changes out of the already-published v0.8.52 repair, but this is on the v0.8.53 review list in #2645. Sorry for the awkward release churn today; this should get a proper code/checks pass now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants