Skip to content

feat(runtime-api): add session save, undo/retry, and snapshot endpoints for GUI#2808

Open
gaord wants to merge 1 commit into
Hmbown:mainfrom
gaord:fix/runtime-api-session-undo-snapshot
Open

feat(runtime-api): add session save, undo/retry, and snapshot endpoints for GUI#2808
gaord wants to merge 1 commit into
Hmbown:mainfrom
gaord:fix/runtime-api-session-undo-snapshot

Conversation

@gaord
Copy link
Copy Markdown
Contributor

@gaord gaord commented Jun 6, 2026

Summary

Add Runtime API endpoints to align GUI capabilities with TUI, following the principle of reusing TUI's existing capabilities rather than reimplementing logic in the API layer.

New Endpoints

Endpoint Description
POST /v1/sessions Save session using Engine::get_session_snapshot(), reusing TUI's build_session_snapshot code path
POST /v1/threads/{id}/undo Undo conversation turns via fork_at_user_message
POST /v1/threads/{id}/patch-undo Snapshot-based file rollback + undo (mirrors TUI's /undo command)
POST /v1/threads/{id}/retry Undo + resend last user message
GET /v1/snapshots List workspace snapshots
POST /v1/snapshots/{id}/restore Restore a snapshot

Key Design Decisions

  1. Session saving reuses Engine state: Instead of reconstructing messages from turn items, save_current_session calls engine.get_session_snapshot() which returns the authoritative messages and token usage directly from the Engine. This ensures data consistency with TUI's internal state.

  2. Oneshot channel for snapshot: Op::GetSessionSnapshot uses a oneshot channel to return the snapshot directly to the caller, avoiding competition with the SSE event stream on the mpsc receiver.

  3. API as thin adapter: Endpoints are thin wrappers that receive HTTP requests, call TUI's existing functions (SessionManager, SnapshotRepo, fork_at_user_message), and return results. No business logic is reimplemented in the API layer.

Changes

  • core/ops.rs: Add SessionSnapshot struct and GetSessionSnapshot Op
  • core/engine.rs: Handle GetSessionSnapshot in the engine loop
  • core/engine/handle.rs: Add EngineHandle::get_session_snapshot() method
  • runtime_api.rs: Add 6 new endpoints
  • runtime_threads.rs: Add get_engine() public wrapper; enhance reconstruct_messages_from_turns with ToolCall/FileChange support

Testing

  • cargo build passes
  • Session save produces correct messages and token counts
  • Undo/retry create forked threads with correct turn history
  • Patch-undo restores files from snapshots and removes conversation turns

Greptile Summary

This PR adds six Runtime API endpoints (session save, undo, patch-undo, retry, and snapshot list/restore) by delegating to existing TUI primitives (fork_at_user_message, SnapshotRepo, SessionManager) rather than reimplementing logic in the API layer.

  • POST /v1/sessions snapshots engine state via a new Op::GetSessionSnapshot oneshot-channel pattern; POST /v1/threads/{id}/retry and /undo wrap fork_at_user_message; GET /v1/snapshots and POST /v1/snapshots/{id}/restore expose the existing SnapshotRepo.
  • reconstruct_messages_from_turns is extended to emit synthetic ToolUse/ToolResult message pairs for ToolCall and FileChange items when a thread is loaded into the engine.
  • Several atomicity gaps exist: retry_thread_turn can leave an orphaned forked thread if start_turn fails; patch_undo_thread_turn restores files before forking the conversation, leaving a split state if the fork step errors; and per-item tool message reconstruction produces an alternating assistant/user pattern that violates the Anthropic API's single-message-per-turn requirement for multi-tool turns.

Confidence Score: 2/5

Not safe to merge without addressing the atomicity gaps in retry and patch-undo, and the message-structure bug in tool call reconstruction.

Three bugs affect correctness on common paths: retry creates a permanent orphaned thread in the DB when validation or turn-start fails; patch-undo leaves the workspace and conversation history out of sync when the fork step errors; and the new ToolCall/FileChange reconstruction emits one assistant message per tool item, which breaks the Anthropic API's requirement that all tool-use blocks for a single turn be grouped in one message — any resumed session with multi-tool turns will fail on the next API call.

crates/tui/src/runtime_api.rs (retry and patch-undo atomicity) and crates/tui/src/runtime_threads.rs (tool message grouping in reconstruct_messages_from_turns)

Important Files Changed

Filename Overview
crates/tui/src/runtime_api.rs Adds 6 new endpoints; retry_thread_turn can orphan forked threads, patch_undo_thread_turn is non-atomic (files restored but conversation not forked on failure), and snapshot endpoints use an inconsistent workspace source vs. patch-undo.
crates/tui/src/runtime_threads.rs Adds public get_engine wrapper and ToolCall/FileChange reconstruction; each tool call item creates its own assistant message, which breaks the Anthropic API's requirement that all tool_use blocks for one turn live in a single message.
crates/tui/src/core/engine.rs Adds GetSessionSnapshot op handler; logic is straightforward and consistent with existing session state fields.
crates/tui/src/core/engine/handle.rs Adds get_session_snapshot() via oneshot channel; the Arc<Mutex<Option>> pattern is needed because Op derives Clone but Sender doesn't — correct and safe.
crates/tui/src/core/ops.rs Adds SessionSnapshot struct and GetSessionSnapshot variant; the Arc<Mutex<Option>> on the Op is necessary due to the Clone derive requirement on the enum.

Sequence Diagram

sequenceDiagram
    participant Client
    participant API as runtime_api.rs
    participant RTM as RuntimeThreadManager
    participant Engine as EngineHandle
    participant SR as SnapshotRepo
    participant SM as SessionManager

    Note over Client,SM: POST /v1/sessions (save)
    Client->>API: POST /v1/sessions
    API->>RTM: get_engine(thread_id)
    RTM-->>API: EngineHandle
    API->>Engine: get_session_snapshot()
    Engine-->>API: SessionSnapshot (via oneshot)
    API->>SM: save_session(session)
    SM-->>Client: session_id + session

    Note over Client,SR: POST /v1/threads/{id}/undo
    Client->>API: "POST /v1/threads/{id}/undo"
    API->>RTM: fork_at_user_message(id, depth)
    RTM-->>Client: thread + original_user_text

    Note over Client,SR: POST /v1/threads/{id}/patch-undo
    Client->>API: "POST /v1/threads/{id}/patch-undo"
    API->>SR: open_or_init(thread.workspace)
    SR->>SR: list + filter + find differing snapshot
    SR->>SR: restore(target.id) ⚠️ files changed here
    API->>RTM: fork_at_user_message(id, depth)
    RTM-->>Client: patch_result + thread

    Note over Client,SR: POST /v1/threads/{id}/retry
    Client->>API: "POST /v1/threads/{id}/retry"
    API->>RTM: fork_at_user_message ⚠️ fork persisted
    RTM-->>API: forked_thread
    API->>RTM: start_turn(forked_thread.id, prompt)
    RTM-->>Client: thread + turn
Loading

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

Reviews (1): Last reviewed commit: "feat(runtime-api): add session save, und..." | Re-trigger Greptile

Greptile also left 5 inline comments on this PR.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 6, 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 several new HTTP endpoints to the runtime API, including session saving, thread turn undoing/retrying, and snapshot listing/restoring. It also updates the TUI engine to support retrieving session snapshots via oneshot channels and improves message reconstruction from tool calls and file changes. The review feedback highlights a compilation error in the git diff calculation where Result::and_then is incorrectly used with an Option-returning closure, suggesting the use of the existing run_git helper instead. Additionally, the feedback recommends explicitly handling std::io::ErrorKind::NotFound when loading sessions to prevent silent overwrites on other I/O errors.

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 +1641 to +1658
let diff_stat =
crate::dependencies::Git::command()
.and_then(|mut git| {
git.args(["diff", "--stat"])
.current_dir(&workspace)
.output()
.ok()
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout)
.trim()
.to_string();
if s.is_empty() {
None
} else {
Some(s)
}
})
});
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.

critical

The diff_stat calculation chains .and_then on crate::dependencies::Git::command(). If Git::command() returns a Result<Command, Error>, calling .and_then with a closure returning Option<String> will cause a compilation error because Result::and_then expects a closure returning a Result. \n\nAdditionally, there is already a local helper function run_git defined in this file that executes git commands and returns Option<String>. We can simplify this entire block and avoid compilation issues by using run_git directly.

                                let diff_stat = run_git(&workspace, &["diff", "--stat"])\n                                    .map(|s| s.trim().to_string())\n                                    .filter(|s| !s.is_empty());

Comment on lines +842 to +866
let session = if let Some(ref existing_id) = req.session_id {
match manager.load_session(existing_id) {
Ok(existing) => {
let mut updated = crate::session_manager::update_session(
existing,
&snapshot.messages,
snapshot.total_tokens,
snapshot.system_prompt.as_ref(),
);
updated.metadata.model = snapshot.model.clone();
updated.metadata.mode = Some(snapshot.mode.clone());
updated
}
Err(_) => {
crate::session_manager::create_saved_session_with_id_and_mode(
existing_id.clone(),
&snapshot.messages,
&snapshot.model,
&snapshot.workspace,
snapshot.total_tokens,
snapshot.system_prompt.as_ref(),
Some(snapshot.mode.as_str()),
)
}
}
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

When attempting to load an existing session, any error (such as PermissionDenied or corrupted JSON data) is caught by the wildcard Err(_) pattern and silently ignored, falling back to creating a new session. This can lead to silent overwrites and data loss of existing session files.\n\nIt is safer to explicitly match std::io::ErrorKind::NotFound for the fallback creation path, and propagate any other I/O errors back to the caller.

    let session = if let Some(ref existing_id) = req.session_id {\n        match manager.load_session(existing_id) {\n            Ok(existing) => {\n                let mut updated = crate::session_manager::update_session(\n                    existing,\n                    &snapshot.messages,\n                    snapshot.total_tokens,\n                    snapshot.system_prompt.as_ref(),\n                );\n                updated.metadata.model = snapshot.model.clone();\n                updated.metadata.mode = Some(snapshot.mode.clone());\n                updated\n            }\n            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {\n                crate::session_manager::create_saved_session_with_id_and_mode(\n                    existing_id.clone(),\n                    &snapshot.messages,\n                    &snapshot.model,\n                    &snapshot.workspace,\n                    snapshot.total_tokens,\n                    snapshot.system_prompt.as_ref(),\n                    Some(snapshot.mode.as_str()),\n                )\n            }\n            Err(e) => {\n                return Err(ApiError::internal(format!("Failed to load existing session: {e}")));\n            }\n        }\n    }

Comment on lines +1754 to +1785
let (forked_thread, original_user_text) = state
.runtime_threads
.fork_at_user_message(&id, depth)
.await
.map_err(map_thread_err)?;

let retry_prompt = req
.prompt
.or(original_user_text)
.unwrap_or_default();
if retry_prompt.trim().is_empty() {
return Err(ApiError::bad_request(
"No user message to retry — the dropped turn had no user text",
));
}

let turn = state
.runtime_threads
.start_turn(
&forked_thread.id,
StartTurnRequest {
prompt: retry_prompt,
model: None,
mode: None,
allow_shell: None,
trust_mode: None,
auto_approve: None,
input_summary: None,
},
)
.await
.map_err(map_thread_err)?;
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.

P1 Orphaned forked thread on retry failure

fork_at_user_message commits the new thread to the database before the subsequent validation and start_turn call. Two paths leave the DB with an empty, unrecoverable thread:

  1. If retry_prompt.trim().is_empty() (line 1764), the fork already exists when the 400 is returned.
  2. If start_turn fails (line 1770), the fork persists with no turns.

In both cases the caller receives an error response but a new thread appears silently in the thread list. Move the retry_prompt validation before the fork_at_user_message call to fix case 1. For case 2, consider deleting the forked thread on start_turn error, or document that callers must handle cleanup.

Fix in Codex Fix in Claude Code Fix in Cursor

Comment on lines +1604 to +1717
async fn patch_undo_thread_turn(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
Json(req): Json<UndoTurnRequest>,
) -> Result<(StatusCode, Json<PatchUndoResponse>), ApiError> {
let depth = req.depth.unwrap_or(0);

// Step 1: Try snapshot-based file rollback (patch_undo).
let thread = state
.runtime_threads
.get_thread(&id)
.await
.map_err(map_thread_err)?;

let workspace = PathBuf::from(&thread.workspace);
let patch_result = match crate::snapshot::SnapshotRepo::open_or_init(&workspace) {
Ok(repo) => {
match repo.list(20) {
Ok(snapshots) => {
// Find the newest tool: or pre-turn: snapshot that differs
// from the current workspace — same logic as TUI's patch_undo.
let target = snapshots
.iter()
.filter(|s| {
s.label.starts_with("tool:") || s.label.starts_with("pre-turn:")
})
.find(|s| {
matches!(
repo.work_tree_matches_snapshot(&s.id),
Ok(false) | Err(_)
)
});

match target {
Some(target) => match repo.restore(&target.id) {
Ok(()) => {
// Compute diff stat for the summary.
let diff_stat =
crate::dependencies::Git::command()
.and_then(|mut git| {
git.args(["diff", "--stat"])
.current_dir(&workspace)
.output()
.ok()
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout)
.trim()
.to_string();
if s.is_empty() {
None
} else {
Some(s)
}
})
});

let short =
&target.id.as_str()[..target.id.as_str().len().min(8)];
let summary = match diff_stat {
Some(ref stat) => {
format!(
"Restored snapshot '{}' ({}). Files affected:\n{stat}",
target.label, short
)
}
None => {
format!(
"Restored snapshot '{}' ({}). No diff changes detected.",
target.label, short
)
}
};

PatchUndoResult {
files_restored: true,
summary: Some(summary),
snapshot_label: Some(target.label.clone()),
}
}
Err(e) => PatchUndoResult {
files_restored: false,
summary: Some(format!("Restore failed: {e}")),
snapshot_label: None,
},
},
None => PatchUndoResult {
files_restored: false,
summary: Some(
"No older tool or pre-turn snapshots differ from the current workspace.".to_string(),
),
snapshot_label: None,
},
}
}
Err(e) => PatchUndoResult {
files_restored: false,
summary: Some(format!("Failed to list snapshots: {e}")),
snapshot_label: None,
},
}
}
Err(e) => PatchUndoResult {
files_restored: false,
summary: Some(format!("Snapshot repo unavailable: {e}")),
snapshot_label: None,
},
};

// Step 2: Remove the last conversation turn (undo_conversation).
let (forked_thread, original_user_text) = state
.runtime_threads
.fork_at_user_message(&id, depth)
.await
.map_err(map_thread_err)?;
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.

P1 Non-atomic file restore + conversation fork in patch_undo

Step 1 restores workspace files from the snapshot, then Step 2 calls fork_at_user_message. If fork_at_user_message fails (e.g., the thread has no user turns, or a DB write fails), the workspace files have already been reverted but the conversation history still contains the "undone" turn. The user is left in a split state: filesystem rolled back, conversation history pointing at turns that reference the old file state. There is no automatic recovery — the user must manually re-apply changes or delete the partially-reverted state.

Fix in Codex Fix in Claude Code Fix in Cursor

Comment on lines +2120 to +2155
TurnItemKind::ToolCall | TurnItemKind::FileChange => {
// Reconstruct a minimal tool_use block for the session
// so the file change cards render correctly when viewing
// a resumed session.
let name = item.summary.clone();
let input: serde_json::Value = item
.metadata
.clone()
.unwrap_or_else(|| serde_json::Value::Object(Default::default()));
let id = item.id.clone();
let tool_use_id = id.clone();
messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.clone(),
caller: None,
}],
});
// Synthesize a tool_result message so downstream parsers
// (e.g. turn restoration) see a complete tool round-trip.
let output = item.detail.clone().unwrap_or_default();
messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id,
content: output,
is_error: Some(matches!(
item.status,
TurnItemLifecycleStatus::Failed
)),
content_blocks: 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.

P1 Multiple tool calls per turn produce invalid message interleaving

When a single assistant turn contained multiple ToolCall (or FileChange) items alongside an AgentMessage, this loop generates alternating assistant:[ToolUse] / user:[ToolResult] messages for each item, rather than batching all ToolUse blocks into one assistant message. The Anthropic API requires that all tool-use blocks from one assistant response live in a single assistant message, followed by a single user message containing all corresponding tool_result blocks. Sending them as separate alternating messages will cause API rejection on the next turn for any resumed session that contained multi-tool turns.

Fix in Codex Fix in Claude Code Fix in Cursor

Comment on lines +1753 to +1768
let depth = req.depth.unwrap_or(0);
let (forked_thread, original_user_text) = state
.runtime_threads
.fork_at_user_message(&id, depth)
.await
.map_err(map_thread_err)?;

let retry_prompt = req
.prompt
.or(original_user_text)
.unwrap_or_default();
if retry_prompt.trim().is_empty() {
return Err(ApiError::bad_request(
"No user message to retry — the dropped turn had no user text",
));
}
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.

P1 Move the empty-prompt validation before the fork so a bad request doesn't leave an orphaned thread in the database.

Suggested change
let depth = req.depth.unwrap_or(0);
let (forked_thread, original_user_text) = state
.runtime_threads
.fork_at_user_message(&id, depth)
.await
.map_err(map_thread_err)?;
let retry_prompt = req
.prompt
.or(original_user_text)
.unwrap_or_default();
if retry_prompt.trim().is_empty() {
return Err(ApiError::bad_request(
"No user message to retry — the dropped turn had no user text",
));
}
let depth = req.depth.unwrap_or(0);
// Validate prompt availability before forking so a failed validation
// cannot leave an orphaned empty thread in the database.
if req.prompt.as_deref().map(str::trim) == Some("") {
return Err(ApiError::bad_request(
"No user message to retry — the dropped turn had no user text",
));
}
let (forked_thread, original_user_text) = state
.runtime_threads
.fork_at_user_message(&id, depth)
.await
.map_err(map_thread_err)?;
let retry_prompt = req
.prompt
.or(original_user_text)
.unwrap_or_default();
if retry_prompt.trim().is_empty() {
let _ = state.runtime_threads.delete_thread(&forked_thread.id).await;
return Err(ApiError::bad_request(
"No user message to retry — the dropped turn had no user text",
));
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code Fix in Cursor

Comment on lines +2495 to 2502
Ok(Json(json!({
"restored": id,
})))
}

fn map_thread_err(err: anyhow::Error) -> ApiError {
let message = err.to_string();
if message.contains("not found") {
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 list_snapshots and restore_snapshot use state.workspace, not the thread's workspace

patch_undo_thread_turn opens the snapshot repo at thread.workspace (line 1618), but list_snapshots and restore_snapshot use state.workspace. If a thread was started with a workspace different from the global state workspace, the IDs returned by GET /v1/snapshots will refer to a different repo than the one PATCH /v1/threads/{id}/patch-undo acts on. A caller that lists snapshots and then uses an ID for a patch-undo may silently restore the wrong snapshot or get a "not found" error.

Fix in Codex Fix in Claude Code Fix in Cursor

@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented Jun 6, 2026

Thanks @gaord, this is a useful GUI-runtime direction and the endpoint list maps well to what desktop clients will eventually need.

For the v0.9 stewardship branch I am going to leave this PR open rather than harvest it as-is. The current integration branch already has a narrower POST /v1/sessions runtime save endpoint in #2762, so that part is partially superseded. The remaining undo/retry/snapshot endpoints need a bit more design work before they are safe to expose: retry should not leave orphaned fork threads when turn start fails, patch-undo should not restore files before the conversation fork is guaranteed, and reconstructed tool messages need to preserve provider-valid multi-tool turn grouping.

So my current disposition is: good intent, not a narrow v0.9 maintainer harvest yet. The safest next shape would be a smaller follow-up that keeps the GUI API thin but adds tests for atomicity/error paths before exposing file restore or retry behavior.

Hmbown added a commit that referenced this pull request Jun 6, 2026
Harvest the safe GUI-facing snapshot list slice from PR #2808 without exposing restore, retry, patch-undo, or other runtime mutation endpoints. The endpoint is protected by the existing runtime API token middleware and mirrors the /restore list bound.

Refs #2808, #2580.

Co-authored-by: gaord <9567937+gaord@users.noreply.github.com>
@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented Jun 6, 2026

Follow-up v0.9 stewardship update: I harvested the safe read-only snapshot-list slice into #2822, which is now merged on codex/v0.9.0-stewardship as bb88358.

What landed:

  • GET /v1/snapshots for GUI/VS Code clients to inspect recent side-git restore points.
  • The endpoint is behind the existing runtime API token middleware.
  • The query mirrors /restore list [N]: default 20, max 100.
  • Focused coverage: snapshots_endpoint_lists_workspace_snapshots.

What I intentionally did not harvest yet: restore, retry, patch-undo, and conversation/file mutation endpoints. Those still need atomicity and message-reconstruction tests so GUI calls cannot leave orphaned fork threads, split filesystem/conversation state, or provider-invalid tool-message history.

Thanks @gaord for the GUI runtime API direction; the #2822 commit, changelog, and PR body preserve that credit.

@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented Jun 6, 2026

Follow-up v0.9 stewardship update: another safe read-only GUI slice landed in #2862 (5bd2f6a99).

What landed from the same GUI/runtime direction:

  • /v1/threads/summary now includes current Git head and dirty worktree metadata in addition to existing workspace / branch fields.
  • /v1/workspace/status now includes the same head / dirty fields.
  • Focused tests cover clean/dirty git workspaces and thread-summary metadata.

What still intentionally did not land from this PR: retry, undo, patch-undo, restore-over-HTTP, and active-session mutation behavior. Those still need atomicity/error-path tests so GUI clients cannot leave orphaned fork threads, split filesystem/conversation state, or provider-invalid reconstructed message history.

Thanks again @gaord for the GUI runtime API direction; #2862 keeps that credit trail in the PR body and changelog while preserving the v0.9 read-only cutline.

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