返回 CodeWhale
sessions.rs
根目录 / crates / tui / src / runtime_api / sessions.rs
1 use std::collections::HashMap;
2 use std::path::PathBuf;
3
4 use axum::Json;
5 use axum::extract::{Path, Query, State};
6 use axum::http::StatusCode;
7 use serde::{Deserialize, Serialize};
8 use serde_json::{Value, json};
9
10 use crate::models::{ContentBlock, Message};
11 use crate::runtime_threads::{
12 CreateThreadRequest, RuntimeTurnStatus, ThreadDetail, ThreadListFilter, TurnItemKind,
13 TurnItemLifecycleStatus,
14 };
15 use crate::session_manager::{
16 SavedSession, SessionListFilter, SessionManager, SessionMetadata, SessionMutator,
17 create_saved_session_with_id_and_mode,
18 };
19 use crate::session_peek::{MAX_PEEK_ENTRIES, SessionPeek, build_peek};
20 use crate::session_projection::{SessionQuery, SessionSortMode, SessionSummary, project_sessions};
21
22 use super::{ApiError, RuntimeApiState, map_thread_err, truncate_text};
23
24 #[derive(Debug, Serialize)]
25 pub(super) struct SessionsResponse {
26 sessions: Vec<SessionMetadata>,
27 }
28
29 #[derive(Debug, Serialize)]
30 pub(super) struct SessionDetailResponse {
31 pub(super) metadata: SessionMetadata,
32 pub(super) messages: Vec<Value>,
33 pub(super) system_prompt: Option<String>,
34 }
35
36 #[derive(Debug, Deserialize)]
37 pub(super) struct CreateSessionRequest {
38 thread_id: String,
39 title: Option<String>,
40 }
41
42 #[derive(Debug, Serialize)]
43 pub(super) struct CreateSessionResponse {
44 session_id: String,
45 thread_id: String,
46 message_count: usize,
47 title: String,
48 }
49
50 #[derive(Debug, Deserialize)]
51 pub(super) struct ResumeSessionRequest {
52 model: Option<String>,
53 mode: Option<String>,
54 }
55
56 #[derive(Debug, Serialize)]
57 pub(super) struct ResumeSessionResponse {
58 thread_id: String,
59 session_id: String,
60 message_count: usize,
61 summary: String,
62 }
63
64 #[derive(Debug, Deserialize)]
65 pub(super) struct SessionsQuery {
66 limit: Option<usize>,
67 search: Option<String>,
68 /// Include archived sessions. Same name and meaning as the `/v1/threads`
69 /// query pair, so a client does not need two mental models (#4397).
70 #[serde(default)]
71 include_archived: Option<bool>,
72 /// Return archived sessions only. Overrides `include_archived`.
73 #[serde(default)]
74 archived_only: Option<bool>,
75 /// Restrict to sessions recorded against this workspace. Absent means
76 /// every workspace, matching the historical behaviour of this route.
77 #[serde(default)]
78 workspace: Option<PathBuf>,
79 /// `recent` (default), `name`, or `size`.
80 #[serde(default)]
81 sort: Option<String>,
82 }
83
84 /// `PATCH /v1/sessions/{id}` body. Both fields are optional; omitting one
85 /// leaves it untouched.
86 #[derive(Debug, Deserialize)]
87 pub(super) struct PatchSessionRequest {
88 #[serde(default)]
89 title: Option<String>,
90 #[serde(default)]
91 archived: Option<bool>,
92 }
93
94 /// Lifecycle receipt for a session mutation.
95 ///
96 /// Deliberately shaped like the thread patch receipt: the caller gets the
97 /// resulting record plus an explicit `changes` map of what actually moved, so
98 /// a no-op patch is distinguishable from an applied one without diffing.
99 #[derive(Debug, Serialize)]
100 pub(super) struct PatchSessionResponse {
101 session: SessionMetadata,
102 changes: HashMap<String, Value>,
103 }
104
105 #[derive(Debug, Deserialize)]
106 pub(super) struct SaveSessionRequest {
107 /// Thread ID to save as a session. If omitted, saves the most recently
108 /// active thread.
109 #[serde(default)]
110 thread_id: Option<String>,
111 /// If provided, update the existing session with this ID instead of
112 /// creating a new one. This matches TUI's `build_session_snapshot`
113 /// behavior where it updates the current session in-place.
114 #[serde(default)]
115 session_id: Option<String>,
116 }
117
118 #[derive(Debug, Serialize)]
119 pub(super) struct SaveSessionResponse {
120 session_id: String,
121 session: SessionDetailResponse,
122 }
123
124 /// Turn a `SessionsQuery` into the shared projection query.
125 ///
126 /// The whole point of routing through [`SessionQuery`] is that the API's
127 /// filter/sort/search semantics are the *same code* the TUI picker and the
128 /// sidebar rail run, not a parallel reimplementation that drifts.
129 fn projection_query(query: &SessionsQuery) -> SessionQuery {
130 let mut projected = SessionQuery::default()
131 .with_filter(SessionListFilter::from_query(
132 query.include_archived,
133 query.archived_only,
134 ))
135 .with_sort(
136 query
137 .sort
138 .as_deref()
139 .map_or(SessionSortMode::Recent, SessionSortMode::from_str_or_recent),
140 )
141 .with_search(query.search.clone().unwrap_or_default())
142 .with_limit(query.limit.unwrap_or(50).clamp(1, 500));
143 if let Some(workspace) = query.workspace.as_deref() {
144 projected = projected.scoped_to(workspace);
145 }
146 projected
147 }
148
149 pub(super) async fn list_sessions(
150 State(state): State<RuntimeApiState>,
151 Query(query): Query<SessionsQuery>,
152 ) -> Result<Json<SessionsResponse>, ApiError> {
153 let manager = SessionManager::new(state.sessions_dir.clone())
154 .map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?;
155 let all = manager
156 .list_sessions()
157 .map_err(|e| ApiError::internal(format!("Failed to list sessions: {e}")))?;
158 // This route keeps returning full `SessionMetadata` for compatibility;
159 // `/v1/sessions/summary` is the projected shape. Membership *and* order
160 // come from the shared projection so the two routes never disagree.
161 let sessions: Vec<SessionMetadata> = project_sessions(&all, &projection_query(&query), None)
162 .into_iter()
163 .filter_map(|summary| all.iter().find(|m| m.id == summary.id).cloned())
164 .collect();
165 Ok(Json(SessionsResponse { sessions }))
166 }
167
168 /// `GET /v1/sessions/summary` — the projected row shape.
169 ///
170 /// Field-compatible with `/v1/threads/summary` so the embedded dashboard can
171 /// render a saved session and a live thread with one row renderer, which is
172 /// what "one projection" means in practice rather than as an aspiration.
173 pub(super) async fn list_sessions_summary(
174 State(state): State<RuntimeApiState>,
175 Query(query): Query<SessionsQuery>,
176 ) -> Result<Json<Vec<SessionSummary>>, ApiError> {
177 let manager = SessionManager::new(state.sessions_dir.clone())
178 .map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?;
179 let all = manager
180 .list_sessions()
181 .map_err(|e| ApiError::internal(format!("Failed to list sessions: {e}")))?;
182 Ok(Json(project_sessions(
183 &all,
184 &projection_query(&query),
185 None,
186 )))
187 }
188
189 /// `PATCH /v1/sessions/{id}` — rename and/or archive a saved session.
190 ///
191 /// Both mutations go through the manager's single writers
192 /// (`rename_session`, `set_session_archived`), which is what keeps the web
193 /// dashboard, the TUI picker, and `/sessions archive` from producing three
194 /// different notions of the same lifecycle state.
195 pub(super) async fn patch_session(
196 State(state): State<RuntimeApiState>,
197 Path(id): Path<String>,
198 Json(req): Json<PatchSessionRequest>,
199 ) -> Result<Json<PatchSessionResponse>, ApiError> {
200 if req.title.is_none() && req.archived.is_none() {
201 return Err(ApiError::bad_request(
202 "PATCH /v1/sessions/{id} requires at least one of `title` or `archived`",
203 ));
204 }
205 let manager = SessionManager::new(state.sessions_dir.clone())
206 .map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?;
207
208 let before = manager
209 .load_session(&id)
210 .map_err(|e| map_session_err(&id, e, "read"))?
211 .metadata;
212 let mut metadata = before.clone();
213 let mut changes: HashMap<String, Value> = HashMap::new();
214
215 if let Some(title) = req.title.as_deref() {
216 // Validate the title before touching the store so a rejected title
217 // reports *why* it was rejected rather than the generic "invalid
218 // session id" that `map_session_err` produces for `InvalidInput`.
219 crate::session_manager::normalize_session_title(title)
220 .map_err(|e| ApiError::bad_request(e.to_string()))?;
221 metadata = manager
222 .rename_session(&id, title, SessionMutator::External)
223 .map_err(|e| map_session_err(&id, e, "rename"))?;
224 if metadata.title != before.title {
225 changes.insert("title".to_string(), json!(metadata.title));
226 }
227 }
228 if let Some(archived) = req.archived {
229 metadata = manager
230 .set_session_archived(&id, archived, SessionMutator::External)
231 .map_err(|e| map_session_err(&id, e, "archive"))?;
232 if metadata.archived != before.archived {
233 changes.insert("archived".to_string(), json!(metadata.archived));
234 }
235 }
236
237 Ok(Json(PatchSessionResponse {
238 session: metadata,
239 changes,
240 }))
241 }
242
243 /// `GET /v1/sessions/{id}` query options.
244 #[derive(Debug, Deserialize, Default)]
245 pub(super) struct SessionDetailQuery {
246 /// When true, return a bounded, redacted [`SessionPeek`] instead of the
247 /// full transcript. The dashboard always asks for this: shipping a
248 /// multi-megabyte transcript to a browser in order to show twelve lines is
249 /// both wasteful and a needless place to re-emit secrets.
250 #[serde(default)]
251 peek: Option<bool>,
252 /// Entry budget for the peek, clamped to [`MAX_PEEK_ENTRIES`].
253 #[serde(default)]
254 entries: Option<usize>,
255 }
256
257 /// Either the full session or a bounded peek, chosen by `?peek=true`.
258 #[derive(Debug, Serialize)]
259 #[serde(untagged)]
260 pub(super) enum SessionDetailOrPeek {
261 Peek(Box<SessionPeek>),
262 Detail(Box<SessionDetailResponse>),
263 }
264
265 pub(super) async fn get_session(
266 State(state): State<RuntimeApiState>,
267 Path(id): Path<String>,
268 Query(query): Query<SessionDetailQuery>,
269 ) -> Result<Json<SessionDetailOrPeek>, ApiError> {
270 let manager = SessionManager::new(state.sessions_dir.clone())
271 .map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?;
272 let session = manager
273 .load_session(&id)
274 .map_err(|e| map_session_err(&id, e, "read"))?;
275
276 if query.peek.unwrap_or(false) {
277 let entries = query.entries.unwrap_or(MAX_PEEK_ENTRIES);
278 return Ok(Json(SessionDetailOrPeek::Peek(Box::new(build_peek(
279 &session, entries,
280 )))));
281 }
282 Ok(Json(SessionDetailOrPeek::Detail(Box::new(
283 session_to_detail(session),
284 ))))
285 }
286
287 pub(super) async fn resume_session_thread(
288 State(state): State<RuntimeApiState>,
289 Path(id): Path<String>,
290 Json(req): Json<ResumeSessionRequest>,
291 ) -> Result<(StatusCode, Json<ResumeSessionResponse>), ApiError> {
292 let manager = SessionManager::new(state.sessions_dir.clone())
293 .map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?;
294 let session = manager
295 .load_session(&id)
296 .map_err(|e| map_session_err(&id, e, "read"))?;
297
298 let model = req.model.unwrap_or_else(|| session.metadata.model.clone());
299 let mode = req.mode.unwrap_or_else(|| {
300 session
301 .metadata
302 .mode
303 .clone()
304 .unwrap_or_else(|| "agent".to_string())
305 });
306
307 let thread = state
308 .runtime_threads
309 .create_thread(CreateThreadRequest {
310 model: Some(model),
311 model_provider: Some(session.metadata.model_provider.clone()),
312 model_provider_id: session.metadata.model_provider_id.clone(),
313 workspace: Some(session.metadata.workspace.clone()),
314 mode: Some(mode),
315 allow_shell: None,
316 trust_mode: None,
317 auto_approve: None,
318 archived: false,
319 system_prompt: session.system_prompt.clone(),
320 task_id: None,
321 ..Default::default()
322 })
323 .await
324 .map_err(map_resume_thread_create_err)?;
325
326 let msg_count = session.messages.len();
327 state
328 .runtime_threads
329 .seed_thread_from_messages(&thread.id, &session.messages)
330 .await
331 .map_err(|e| ApiError::internal(format!("Failed to seed thread history: {e}")))?;
332
333 // Link the session to the new thread so that `ensure_engine_loaded`
334 // can restore the full message history from the session file.
335 if let Err(e) = state
336 .runtime_threads
337 .set_thread_session_id(&thread.id, &id)
338 .await
339 {
340 let session_ref = crate::utils::redacted_identifier_for_log(&id);
341 tracing::warn!(
342 session = %session_ref,
343 thread_id = %thread.id,
344 error = %e,
345 "Failed to link session to thread"
346 );
347 }
348
349 let summary = format!(
350 "Resumed session '{}' ({} messages) into thread {}",
351 session.metadata.title, msg_count, thread.id
352 );
353
354 Ok((
355 StatusCode::CREATED,
356 Json(ResumeSessionResponse {
357 thread_id: thread.id,
358 session_id: id,
359 message_count: msg_count,
360 summary,
361 }),
362 ))
363 }
364
365 pub(super) async fn create_session_from_thread(
366 State(state): State<RuntimeApiState>,
367 Json(req): Json<CreateSessionRequest>,
368 ) -> Result<(StatusCode, Json<CreateSessionResponse>), ApiError> {
369 let thread_id = req.thread_id.trim();
370 if thread_id.is_empty() {
371 return Err(ApiError::bad_request("thread_id is required"));
372 }
373
374 let detail = state
375 .runtime_threads
376 .get_thread_detail(thread_id)
377 .await
378 .map_err(map_thread_err)?;
379
380 if thread_detail_has_live_work(&detail) {
381 return Err(ApiError {
382 status: StatusCode::CONFLICT,
383 message: format!(
384 "Thread {thread_id} has a queued or active turn; wait for completion before saving as a session"
385 ),
386 });
387 }
388
389 let messages = messages_from_thread_detail(&detail);
390 if messages.is_empty() {
391 return Err(ApiError::bad_request(format!(
392 "Thread {thread_id} has no user or assistant messages to save"
393 )));
394 }
395
396 let manager = SessionManager::new(state.sessions_dir.clone())
397 .map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?;
398 let total_tokens = total_tokens_from_thread_detail(&detail);
399 let session_handle = uuid::Uuid::new_v4().to_string();
400 let mut session = create_saved_session_with_id_and_mode(
401 session_handle.clone(),
402 &messages,
403 &detail.thread.model,
404 &detail.thread.workspace,
405 total_tokens,
406 None,
407 Some(&detail.thread.mode),
408 );
409 {
410 let config = state.runtime_threads.read_config();
411 stamp_session_provider_from_thread(&config, &detail, &mut session.metadata).map_err(
412 |reason| {
413 ApiError::bad_request(format!(
414 "Thread {thread_id} provider route is unavailable; session export will not fall back: {reason}"
415 ))
416 },
417 )?;
418 }
419 session.system_prompt = detail.thread.system_prompt.clone();
420
421 if let Some(title) =
422 session_title_override(req.title.as_deref(), detail.thread.title.as_deref())
423 {
424 session.metadata.title = title;
425 }
426 let title = session.metadata.title.clone();
427 let message_count = session.metadata.message_count;
428
429 manager
430 .save_session(&session)
431 .map_err(|e| ApiError::internal(format!("Failed to save session: {e}")))?;
432
433 // Link the session to the thread so that `ensure_engine_loaded` can
434 // restore the full message history from the session file.
435 if let Err(e) = state
436 .runtime_threads
437 .set_thread_session_id(&detail.thread.id, &session_handle)
438 .await
439 {
440 let session_ref = crate::utils::redacted_identifier_for_log(&session_handle);
441 tracing::warn!(
442 session = %session_ref,
443 thread_id = %detail.thread.id,
444 error = %e,
445 "Failed to link session to thread"
446 );
447 }
448
449 Ok((
450 StatusCode::CREATED,
451 Json(CreateSessionResponse {
452 session_id: session_handle,
453 thread_id: detail.thread.id,
454 message_count,
455 title,
456 }),
457 ))
458 }
459
460 pub(super) fn stamp_session_provider_from_thread(
461 config: &crate::config::Config,
462 detail: &ThreadDetail,
463 metadata: &mut crate::session_manager::SessionMetadata,
464 ) -> Result<(), String> {
465 let thread_has_route = detail
466 .thread
467 .model_provider
468 .as_deref()
469 .is_some_and(|provider| !provider.trim().is_empty())
470 || detail.thread.model_provider_id.is_some();
471 let provider_identity = if thread_has_route {
472 config.resolve_persisted_provider_identity(
473 detail.thread.model_provider.as_deref(),
474 detail.thread.model_provider_id.as_deref(),
475 )?
476 } else if let Some(turn) = detail.turns.iter().rev().find(|turn| {
477 turn.effective_provider
478 .as_deref()
479 .is_some_and(|provider| !provider.trim().is_empty())
480 || turn.effective_provider_id.is_some()
481 }) {
482 config.resolve_persisted_provider_identity(
483 turn.effective_provider.as_deref(),
484 turn.effective_provider_id.as_deref(),
485 )?
486 } else {
487 let key = config
488 .provider
489 .as_deref()
490 .unwrap_or(crate::config::ApiProvider::Deepseek.as_str());
491 config.resolve_provider_identity(key)?
492 };
493 metadata.set_model_provider_route(
494 provider_identity.provider.as_str(),
495 provider_identity.persisted_id(),
496 );
497 Ok(())
498 }
499
500 fn thread_detail_has_live_work(detail: &ThreadDetail) -> bool {
501 detail.turns.iter().any(|turn| {
502 matches!(
503 turn.status,
504 RuntimeTurnStatus::Queued | RuntimeTurnStatus::InProgress
505 )
506 }) || detail.items.iter().any(|item| {
507 matches!(
508 item.status,
509 TurnItemLifecycleStatus::Queued | TurnItemLifecycleStatus::InProgress
510 )
511 })
512 }
513
514 pub(super) fn messages_from_thread_detail(detail: &ThreadDetail) -> Vec<Message> {
515 let items_by_id: HashMap<&str, _> = detail
516 .items
517 .iter()
518 .map(|item| (item.id.as_str(), item))
519 .collect();
520 let mut messages = Vec::new();
521
522 for turn in &detail.turns {
523 let mut assistant_blocks: Vec<ContentBlock> = Vec::new();
524 let mut user_blocks: Vec<ContentBlock> = Vec::new();
525 let flush_assistant = |blocks: &mut Vec<ContentBlock>, msgs: &mut Vec<Message>| {
526 if !blocks.is_empty() {
527 msgs.push(Message {
528 role: "assistant".to_string(),
529 content: std::mem::take(blocks),
530 });
531 }
532 };
533 let flush_user = |blocks: &mut Vec<ContentBlock>, msgs: &mut Vec<Message>| {
534 if !blocks.is_empty() {
535 msgs.push(Message {
536 role: "user".to_string(),
537 content: std::mem::take(blocks),
538 });
539 }
540 };
541
542 for item_id in &turn.item_ids {
543 let Some(item) = items_by_id.get(item_id.as_str()) else {
544 continue;
545 };
546 match item.kind {
547 TurnItemKind::UserMessage => {
548 flush_assistant(&mut assistant_blocks, &mut messages);
549
550 let text = item.detail.as_deref().map(str::trim).unwrap_or("");
551 if !text.is_empty() {
552 user_blocks.push(ContentBlock::Text {
553 text: text.to_string(),
554 cache_control: None,
555 });
556 }
557 }
558 TurnItemKind::AgentMessage => {
559 flush_user(&mut user_blocks, &mut messages);
560 let text = item.detail.as_deref().map(str::trim).unwrap_or("");
561 if !text.is_empty() {
562 assistant_blocks.push(ContentBlock::Text {
563 text: text.to_string(),
564 cache_control: None,
565 });
566 }
567 }
568 TurnItemKind::AgentReasoning => {
569 flush_user(&mut user_blocks, &mut messages);
570 let thinking = item.detail.as_deref().map(str::trim).unwrap_or("");
571 if !thinking.is_empty() {
572 assistant_blocks.push(ContentBlock::Thinking {
573 thinking: thinking.to_string(),
574 signature: None,
575 });
576 }
577 }
578 TurnItemKind::ToolCall => {
579 // Check metadata to distinguish tool_use from tool_result.
580 let meta = item.metadata.as_ref();
581 let is_tool_result = meta.and_then(|m| m.get("tool_result_for")).is_some();
582 if is_tool_result {
583 flush_assistant(&mut assistant_blocks, &mut messages);
584
585 let tool_use_id = meta
586 .and_then(|m| m.get("tool_result_for"))
587 .and_then(|v| v.as_str())
588 .unwrap_or("")
589 .to_string();
590 let content = item.detail.as_deref().unwrap_or("").to_string();
591 let is_error = meta
592 .and_then(|m| m.get("is_error"))
593 .and_then(|v| v.as_bool())
594 .unwrap_or(false);
595 let content_blocks = meta
596 .and_then(|m| m.get("content_blocks"))
597 .and_then(|v| v.as_array())
598 .cloned();
599 user_blocks.push(ContentBlock::ToolResult {
600 tool_use_id,
601 content,
602 is_error: if is_error { Some(true) } else { None },
603 content_blocks,
604 });
605 } else {
606 flush_user(&mut user_blocks, &mut messages);
607 let tool_use_id = meta
608 .and_then(|m| m.get("tool_use_id"))
609 .and_then(|v| v.as_str())
610 .unwrap_or("")
611 .to_string();
612 let tool_name = meta
613 .and_then(|m| m.get("tool_name"))
614 .and_then(|v| v.as_str())
615 .unwrap_or("")
616 .to_string();
617 let input_str = item.detail.as_deref().unwrap_or("{}");
618 let input: Value = serde_json::from_str(input_str).unwrap_or(Value::Null);
619 assistant_blocks.push(ContentBlock::ToolUse {
620 id: tool_use_id,
621 name: tool_name,
622 input,
623 caller: None,
624 });
625 }
626 }
627 // Skip other item kinds (file_change, command_execution, etc.)
628 _ => {}
629 }
630 }
631 flush_assistant(&mut assistant_blocks, &mut messages);
632 flush_user(&mut user_blocks, &mut messages);
633 }
634
635 messages
636 }
637
638 /// `PUT /v1/sessions` — save a thread's current engine state as a session.
639 ///
640 /// Unlike `POST /v1/sessions` (which reconstructs messages from stored turn
641 /// items), this endpoint asks the engine for its live session snapshot so
642 /// token counts and message ordering are authoritative.
643 pub(super) async fn save_current_session(
644 State(state): State<RuntimeApiState>,
645 Json(req): Json<SaveSessionRequest>,
646 ) -> Result<Json<SaveSessionResponse>, ApiError> {
647 // Find the thread to save.
648 let thread_id = match req.thread_id {
649 Some(id) => id,
650 None => {
651 // Find the most recently updated thread.
652 let threads = state
653 .runtime_threads
654 .list_threads(ThreadListFilter::IncludeArchived, Some(100))
655 .await
656 .map_err(map_thread_err)?;
657 threads
658 .into_iter()
659 .max_by_key(|t| t.updated_at)
660 .map(|t| t.id)
661 .ok_or_else(|| ApiError::bad_request("No threads to save"))?
662 }
663 };
664
665 // Get the engine handle (loads the thread into an engine if needed),
666 // then request a session snapshot. This reuses the same code path as
667 // TUI's `build_session_snapshot`: the engine holds the authoritative
668 // messages and token usage, so we don't need to reconstruct from turns.
669 let engine = state
670 .runtime_threads
671 .get_engine(&thread_id)
672 .await
673 .map_err(|e| ApiError::internal(format!("Failed to get engine for thread: {e}")))?;
674
675 let snapshot = engine
676 .get_session_snapshot()
677 .await
678 .map_err(|e| ApiError::internal(format!("Failed to get session snapshot: {e}")))?;
679
680 let manager = SessionManager::new(state.sessions_dir.clone())
681 .map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?;
682
683 // Build or update the session, mirroring TUI's `build_session_snapshot`.
684 // Only `io::ErrorKind::NotFound` falls back to creating a new session;
685 // other I/O errors (e.g. PermissionDenied) are propagated so callers
686 // don't silently overwrite a corrupt or inaccessible session file.
687 let session = if let Some(ref existing_id) = req.session_id {
688 match manager.load_session(existing_id) {
689 Ok(existing) => {
690 let mut updated = crate::session_manager::update_session(
691 existing,
692 &snapshot.messages,
693 snapshot.total_tokens,
694 snapshot.system_prompt.as_ref(),
695 );
696 updated.metadata.model = snapshot.model.clone();
697 updated.metadata.set_model_provider_route(
698 &snapshot.model_provider,
699 snapshot.model_provider_id.as_deref(),
700 );
701 updated.metadata.mode = Some(snapshot.mode.clone());
702 updated
703 }
704 Err(e) => {
705 if e.kind() == std::io::ErrorKind::NotFound {
706 let mut session = crate::session_manager::create_saved_session_with_id_and_mode(
707 existing_id.clone(),
708 &snapshot.messages,
709 &snapshot.model,
710 &snapshot.workspace,
711 snapshot.total_tokens,
712 snapshot.system_prompt.as_ref(),
713 Some(snapshot.mode.as_str()),
714 );
715 session.metadata.set_model_provider_route(
716 &snapshot.model_provider,
717 snapshot.model_provider_id.as_deref(),
718 );
719 session
720 } else {
721 return Err(ApiError::internal(format!(
722 "Failed to load session {existing_id}: {e}"
723 )));
724 }
725 }
726 }
727 } else {
728 let mut session = crate::session_manager::create_saved_session_with_mode(
729 &snapshot.messages,
730 &snapshot.model,
731 &snapshot.workspace,
732 snapshot.total_tokens,
733 snapshot.system_prompt.as_ref(),
734 Some(snapshot.mode.as_str()),
735 );
736 session.metadata.set_model_provider_route(
737 &snapshot.model_provider,
738 snapshot.model_provider_id.as_deref(),
739 );
740 session
741 };
742
743 // Save the session.
744 manager
745 .save_session(&session)
746 .map_err(|e| ApiError::internal(format!("Failed to save session: {e}")))?;
747
748 // Link the session to the thread so that `ensure_engine_loaded` can
749 // restore the full message history (including thinking/tool blocks)
750 // from the session file instead of reconstructing from turns.
751 let session_handle = session.metadata.id.clone();
752 if let Err(e) = state
753 .runtime_threads
754 .set_thread_session_id(&thread_id, &session_handle)
755 .await
756 {
757 let session_ref = crate::utils::redacted_identifier_for_log(&session_handle);
758 tracing::warn!(
759 session = %session_ref,
760 thread_id = %thread_id,
761 error = %e,
762 "Failed to link session to thread"
763 );
764 }
765
766 Ok(Json(SaveSessionResponse {
767 session_id: session_handle,
768 session: session_to_detail(session),
769 }))
770 }
771
772 fn total_tokens_from_thread_detail(detail: &ThreadDetail) -> u64 {
773 detail
774 .turns
775 .iter()
776 .filter_map(|turn| turn.usage.as_ref())
777 .map(|usage| u64::from(usage.input_tokens) + u64::from(usage.output_tokens))
778 .sum()
779 }
780
781 fn session_title_override(requested: Option<&str>, thread_title: Option<&str>) -> Option<String> {
782 requested
783 .and_then(nonempty_title)
784 .or_else(|| thread_title.and_then(nonempty_title))
785 }
786
787 fn nonempty_title(title: &str) -> Option<String> {
788 let trimmed = title.trim();
789 if trimmed.is_empty() {
790 None
791 } else {
792 Some(truncate_text(trimmed, 50))
793 }
794 }
795
796 pub(super) async fn delete_session(
797 State(state): State<RuntimeApiState>,
798 Path(id): Path<String>,
799 ) -> Result<StatusCode, ApiError> {
800 let manager = SessionManager::new(state.sessions_dir.clone())
801 .map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?;
802 manager
803 .delete_session(&id)
804 .map_err(|e| map_session_err(&id, e, "delete"))?;
805 Ok(StatusCode::NO_CONTENT)
806 }
807
808 pub(super) fn session_to_detail(session: SavedSession) -> SessionDetailResponse {
809 let messages: Vec<Value> = session
810 .messages
811 .iter()
812 .map(|msg| {
813 let content_blocks: Vec<Value> = msg
814 .content
815 .iter()
816 .map(|block| match block {
817 crate::models::ContentBlock::Text { text, .. } => {
818 json!({ "type": "text", "text": text })
819 }
820 crate::models::ContentBlock::Thinking { thinking, .. } => {
821 json!({ "type": "thinking", "text": thinking })
822 }
823 crate::models::ContentBlock::ToolUse {
824 id,
825 name,
826 input,
827 caller,
828 } => {
829 let mut obj =
830 json!({ "type": "tool_use", "id": id, "name": name, "input": input });
831 if let Some(caller) = caller {
832 obj["caller"] = json!(caller);
833 }
834 obj
835 }
836 crate::models::ContentBlock::ToolResult {
837 tool_use_id,
838 content,
839 is_error,
840 content_blocks,
841 ..
842 } => {
843 let mut obj = json!({ "type": "tool_result", "tool_use_id": tool_use_id });
844 if let Some(cbs) = content_blocks {
845 obj["content_blocks"] = json!(cbs);
846 if !content.is_empty() {
847 obj["content"] = json!(content);
848 }
849 } else {
850 obj["content"] = json!(content);
851 }
852 if let Some(e) = is_error {
853 obj["is_error"] = json!(e);
854 }
855 obj
856 }
857 crate::models::ContentBlock::ServerToolUse { id, name, input } => {
858 json!({ "type": "tool_use", "id": id, "name": name, "input": input })
859 }
860 crate::models::ContentBlock::ToolSearchToolResult {
861 tool_use_id,
862 content,
863 } => {
864 json!({ "type": "tool_result", "tool_use_id": tool_use_id, "content": content })
865 }
866 crate::models::ContentBlock::CodeExecutionToolResult {
867 tool_use_id,
868 content,
869 } => {
870 json!({ "type": "tool_result", "tool_use_id": tool_use_id, "content": content })
871 }
872 crate::models::ContentBlock::ImageUrl { .. } => Value::Null,
873 })
874 .collect();
875 json!({
876 "role": msg.role,
877 "content": content_blocks,
878 })
879 })
880 .collect();
881 SessionDetailResponse {
882 metadata: session.metadata,
883 messages,
884 system_prompt: session.system_prompt,
885 }
886 }
887
888 fn map_session_err(id: &str, err: std::io::Error, action: &str) -> ApiError {
889 match err.kind() {
890 std::io::ErrorKind::NotFound => ApiError::not_found(format!("Session '{id}' not found")),
891 std::io::ErrorKind::InvalidData => {
892 ApiError::bad_request(format!("Failed to parse session '{id}': {err}"))
893 }
894 std::io::ErrorKind::InvalidInput => {
895 ApiError::bad_request(format!("Invalid session id '{id}'"))
896 }
897 // The session is open in an interactive Codewhale session, which holds
898 // the authoritative copy in memory. Fail closed with a typed conflict
899 // rather than write something its next autosave would revert.
900 std::io::ErrorKind::ResourceBusy => ApiError {
901 status: StatusCode::CONFLICT,
902 message: err.to_string(),
903 },
904 _ => ApiError::internal(format!("Failed to {action} session '{id}': {err}")),
905 }
906 }
907
908 fn map_resume_thread_create_err(err: anyhow::Error) -> ApiError {
909 let reason = err.to_string();
910 let message = format!("Failed to create thread: {reason}");
911 if reason.starts_with("saved session has an empty provider identity")
912 || reason.starts_with("saved session requires custom provider")
913 || reason.starts_with("legacy session records only the generic `custom` provider kind")
914 || reason.starts_with("legacy `provider = \"custom\"`")
915 {
916 ApiError::bad_request(message)
917 } else {
918 // Thread-store writes, event persistence, and other runtime failures
919 // are server-side faults; never disguise them as a client config error.
920 ApiError::internal(message)
921 }
922 }
923
924 #[cfg(test)]
925 mod session_query_tests {
926 use super::*;
927
928 fn query(
929 include_archived: Option<bool>,
930 archived_only: Option<bool>,
931 sort: Option<&str>,
932 workspace: Option<&str>,
933 limit: Option<usize>,
934 ) -> SessionsQuery {
935 SessionsQuery {
936 limit,
937 search: Some("whale".to_string()),
938 include_archived,
939 archived_only,
940 workspace: workspace.map(PathBuf::from),
941 sort: sort.map(str::to_string),
942 }
943 }
944
945 #[test]
946 fn archive_params_resolve_like_the_threads_routes() {
947 assert_eq!(
948 projection_query(&query(None, None, None, None, None)).filter,
949 SessionListFilter::ActiveOnly
950 );
951 assert_eq!(
952 projection_query(&query(Some(true), None, None, None, None)).filter,
953 SessionListFilter::IncludeArchived
954 );
955 assert_eq!(
956 projection_query(&query(Some(true), Some(true), None, None, None)).filter,
957 SessionListFilter::ArchivedOnly
958 );
959 }
960
961 #[test]
962 fn sort_and_workspace_scope_flow_through_and_bad_sorts_fall_back() {
963 let projected = projection_query(&query(None, None, Some("name"), Some("/repo"), Some(9)));
964 assert_eq!(projected.sort, SessionSortMode::Name);
965 // `Path` in this module is `axum::extract::Path`; spell out the std one.
966 assert_eq!(
967 projected.workspace_scope.as_deref(),
968 Some(std::path::Path::new("/repo"))
969 );
970 assert_eq!(projected.limit, 9);
971 assert_eq!(projected.search, "whale");
972
973 // An unknown sort must not fail the request — a stale client should
974 // still get a listing, just in the default order.
975 assert_eq!(
976 projection_query(&query(None, None, Some("nonsense"), None, None)).sort,
977 SessionSortMode::Recent
978 );
979 }
980
981 #[test]
982 fn limit_is_clamped_at_both_ends() {
983 assert_eq!(
984 projection_query(&query(None, None, None, None, Some(0))).limit,
985 1
986 );
987 assert_eq!(
988 projection_query(&query(None, None, None, None, Some(10_000))).limit,
989 500
990 );
991 // Absent limit keeps the historical page size.
992 assert_eq!(
993 projection_query(&query(None, None, None, None, None)).limit,
994 50
995 );
996 }
997
998 #[test]
999 fn absent_workspace_means_every_workspace() {
1000 assert!(
1001 projection_query(&query(None, None, None, None, None))
1002 .workspace_scope
1003 .is_none(),
1004 "the API must not silently scope to the runtime's own CWD"
1005 );
1006 }
1007 }
1008
1009 #[cfg(test)]
1010 mod resume_thread_error_tests {
1011 use super::*;
1012
1013 #[test]
1014 fn provider_config_errors_are_client_errors_but_storage_errors_stay_internal() {
1015 let provider = map_resume_thread_create_err(anyhow::anyhow!(
1016 "saved session requires custom provider 'lm-studio', but `[providers.lm-studio]` is missing"
1017 ));
1018 assert_eq!(provider.status, StatusCode::BAD_REQUEST);
1019
1020 let storage = map_resume_thread_create_err(anyhow::anyhow!(
1021 "Failed to save runtime thread: permission denied"
1022 ));
1023 assert_eq!(storage.status, StatusCode::INTERNAL_SERVER_ERROR);
1024 }
1025 }
1026
1026 lines RUST