Skip to main content

routa_server/api/
sessions.rs

1use axum::{
2    extract::{Path, Query, State},
3    http::HeaderMap,
4    routing::{get, post},
5    Json, Router,
6};
7use chrono::{DateTime, Utc};
8use regex::Regex;
9use routa_core::trace::{TraceEventType, TraceQuery, TraceReader};
10use serde::Deserialize;
11use serde::Serialize;
12use serde_json::Value;
13use std::path::{Path as FsPath, PathBuf};
14
15use crate::application::sessions::{
16    ListSessionsQuery as SessionListQuery, SessionApplicationService,
17};
18use crate::error::ServerError;
19use crate::state::AppState;
20
21pub fn router() -> Router<AppState> {
22    Router::new()
23        .route("/", get(list_sessions))
24        .route(
25            "/{session_id}",
26            get(get_session)
27                .patch(rename_session)
28                .delete(delete_session),
29        )
30        .route("/{session_id}/history", get(get_session_history))
31        .route("/{session_id}/transcript", get(get_session_transcript))
32        .route("/{session_id}/reposlide-result", get(get_reposlide_result))
33        .route(
34            "/{session_id}/reposlide-result/download",
35            get(download_reposlide_result),
36        )
37        .route("/{session_id}/context", get(get_session_context))
38        .route("/{session_id}/disconnect", post(disconnect_session))
39        .route("/{session_id}/fork", post(fork_session))
40}
41
42#[derive(Debug, Serialize)]
43#[serde(rename_all = "camelCase")]
44struct SessionTranscriptPayload {
45    session_id: String,
46    history: Vec<Value>,
47    messages: Vec<TranscriptMessage>,
48    source: &'static str,
49    history_message_count: usize,
50    trace_message_count: usize,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    latest_event_kind: Option<String>,
53}
54
55#[derive(Debug, Serialize)]
56#[serde(rename_all = "camelCase")]
57struct TranscriptMessage {
58    id: String,
59    role: &'static str,
60    content: String,
61    timestamp: String,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    tool_name: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    tool_status: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    tool_call_id: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    tool_raw_input: Option<Value>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    tool_raw_output: Option<Value>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    raw_data: Option<Value>,
74}
75
76#[derive(Debug, Serialize)]
77#[serde(rename_all = "camelCase")]
78struct RepoSlideResultPayload {
79    session_id: String,
80    result: RepoSlideSessionResult,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    latest_event_kind: Option<String>,
83    source: &'static str,
84}
85
86#[derive(Debug, Serialize)]
87#[serde(rename_all = "camelCase")]
88struct RepoSlideSessionResult {
89    status: &'static str,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    deck_path: Option<String>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    download_url: Option<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    latest_assistant_message: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    summary: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    updated_at: Option<String>,
100}
101
102#[derive(Debug, Deserialize)]
103#[serde(rename_all = "camelCase")]
104struct ListSessionsQuery {
105    workspace_id: Option<String>,
106    parent_session_id: Option<String>,
107    limit: Option<usize>,
108}
109
110/// GET /api/sessions — List ACP sessions.
111/// Compatible with the Next.js frontend's session-panel.tsx and chat-panel.tsx.
112///
113/// Merges in-memory sessions with persisted sessions from the database.
114async fn list_sessions(
115    State(state): State<AppState>,
116    Query(query): Query<ListSessionsQuery>,
117) -> Json<serde_json::Value> {
118    let service = SessionApplicationService::new(state);
119    let sessions = service
120        .list_sessions(SessionListQuery {
121            workspace_id: query.workspace_id,
122            parent_session_id: query.parent_session_id,
123            limit: query.limit,
124        })
125        .await;
126
127    Json(serde_json::json!({ "sessions": sessions }))
128}
129
130/// GET /api/sessions/{session_id} — Get session metadata.
131///
132/// First tries to get session from in-memory AcpManager.
133/// Falls back to database if session is not in memory (e.g. after server restart).
134async fn get_session(
135    State(state): State<AppState>,
136    Path(session_id): Path<String>,
137) -> Result<Json<serde_json::Value>, ServerError> {
138    let service = SessionApplicationService::new(state);
139    let session = service.get_session(&session_id).await?;
140
141    Ok(Json(serde_json::json!({
142        "session": session
143    })))
144}
145
146#[derive(Debug, Deserialize)]
147struct RenameSessionRequest {
148    name: String,
149}
150
151/// PATCH /api/sessions/{session_id} — Rename a session.
152async fn rename_session(
153    State(state): State<AppState>,
154    Path(session_id): Path<String>,
155    Json(body): Json<RenameSessionRequest>,
156) -> Result<Json<serde_json::Value>, ServerError> {
157    let name = body.name.trim();
158    if name.is_empty() {
159        return Err(ServerError::BadRequest("Invalid name".to_string()));
160    }
161
162    // Update in-memory (may be None if session is DB-only after restart)
163    let in_memory_found = state
164        .acp_manager
165        .rename_session(&session_id, name)
166        .await
167        .is_some();
168
169    // Always persist the rename to the database
170    state.acp_session_store.rename(&session_id, name).await?;
171
172    // If neither memory nor DB had the session, return 404
173    if !in_memory_found {
174        // Verify it exists in DB (rename is idempotent, so check row count via get)
175        if state.acp_session_store.get(&session_id).await?.is_none() {
176            return Err(ServerError::NotFound("Session not found".to_string()));
177        }
178    }
179
180    Ok(Json(serde_json::json!({ "ok": true })))
181}
182
183/// DELETE /api/sessions/{session_id} — Delete a session.
184async fn delete_session(
185    State(state): State<AppState>,
186    Path(session_id): Path<String>,
187) -> Result<Json<serde_json::Value>, ServerError> {
188    // Try to kill in-memory process (may be None if DB-only after restart)
189    let in_memory_found = state
190        .acp_manager
191        .delete_session(&session_id)
192        .await
193        .is_some();
194
195    // Always delete from the database
196    state.acp_session_store.delete(&session_id).await?;
197
198    // If neither memory nor DB had the session, return 404
199    if !in_memory_found {
200        // We already deleted from DB; if 0 rows, it was already gone
201        // Return 404 only when we have confirmation it doesn't exist
202        // (delete is idempotent, so we just return ok even if not found)
203    }
204
205    Ok(Json(serde_json::json!({ "ok": true })))
206}
207
208/// POST /api/sessions/{session_id}/disconnect — Disconnect and kill an active session process.
209///
210/// Persists history to the database, then kills the in-memory process.
211/// Unlike DELETE, this does not remove the session from the database.
212async fn disconnect_session(
213    State(state): State<AppState>,
214    Path(session_id): Path<String>,
215) -> Result<Json<serde_json::Value>, ServerError> {
216    // Check if session exists in memory
217    let session = state.acp_manager.get_session(&session_id).await;
218    if session.is_none() {
219        return Err(ServerError::NotFound(format!(
220            "Session {session_id} not found"
221        )));
222    }
223
224    // Persist history before killing
225    if let Some(history) = state.acp_manager.get_session_history(&session_id).await {
226        if !history.is_empty() {
227            let _ = state
228                .acp_session_store
229                .save_history(&session_id, &history)
230                .await;
231        }
232    }
233
234    // Kill the process
235    state.acp_manager.kill_session(&session_id).await;
236
237    Ok(Json(serde_json::json!({ "ok": true })))
238}
239
240/// POST /api/sessions/{session_id}/fork — Fork a session.
241///
242/// Creates a new session that inherits the parent's provider, workspace, and settings.
243async fn fork_session(
244    State(state): State<AppState>,
245    Path(session_id): Path<String>,
246) -> Result<Json<serde_json::Value>, ServerError> {
247    // Try in-memory first, then DB
248    let (provider, workspace_id, cwd) =
249        if let Some(mem) = state.acp_manager.get_session(&session_id).await {
250            (
251                mem.provider.clone(),
252                mem.workspace_id.clone(),
253                mem.cwd.clone(),
254            )
255        } else if let Ok(Some(row)) = state.acp_session_store.get(&session_id).await {
256            (
257                row.provider.clone(),
258                row.workspace_id.clone(),
259                row.cwd.clone(),
260            )
261        } else {
262            return Err(ServerError::NotFound(format!(
263                "Session {session_id} not found"
264            )));
265        };
266
267    let new_id = uuid::Uuid::new_v4().to_string();
268
269    state
270        .acp_session_store
271        .create(
272            routa_core::store::acp_session_store::CreateAcpSessionParams {
273                id: &new_id,
274                cwd: &cwd,
275                branch: None,
276                workspace_id: &workspace_id,
277                provider: provider.as_deref(),
278                role: None,
279                custom_command: None,
280                custom_args: None,
281                parent_session_id: Some(&session_id),
282            },
283        )
284        .await
285        .map_err(|e| ServerError::Internal(format!("Failed to create forked session: {e}")))?;
286
287    Ok(Json(serde_json::json!({
288        "sessionId": new_id,
289        "parentSessionId": session_id,
290        "provider": provider,
291        "workspaceId": workspace_id,
292    })))
293}
294
295#[derive(Debug, Deserialize)]
296#[serde(rename_all = "camelCase")]
297struct HistoryQuery {
298    consolidated: Option<bool>,
299}
300
301/// GET /api/sessions/{session_id}/history — Get session message history.
302///
303/// First tries to get history from in-memory AcpManager.
304/// Falls back to database if in-memory is empty (e.g. after server restart).
305async fn get_session_history(
306    State(state): State<AppState>,
307    Path(session_id): Path<String>,
308    Query(query): Query<HistoryQuery>,
309) -> Result<Json<serde_json::Value>, ServerError> {
310    let service = SessionApplicationService::new(state);
311    let result = service
312        .get_session_history(&session_id, query.consolidated.unwrap_or(false))
313        .await?;
314
315    Ok(Json(serde_json::json!({ "history": result })))
316}
317
318/// GET /api/sessions/{session_id}/transcript — Get preferred transcript payload.
319///
320/// Mirrors the Next.js transcript route shape used by chat panels.
321async fn get_session_transcript(
322    State(state): State<AppState>,
323    Path(session_id): Path<String>,
324) -> Result<Json<serde_json::Value>, ServerError> {
325    let service = SessionApplicationService::new(state);
326    let history = service.get_session_history(&session_id, true).await?;
327    let cwd = std::env::current_dir()
328        .map_err(|error| ServerError::Internal(format!("Failed to get cwd: {error}")))?;
329    let traces = TraceReader::new(&cwd)
330        .query(&TraceQuery {
331            session_id: Some(session_id.clone()),
332            ..TraceQuery::default()
333        })
334        .await
335        .map_err(|error| ServerError::Internal(format!("Failed to query traces: {error}")))?;
336
337    let payload = build_transcript_payload(&session_id, history, traces);
338    Ok(Json(serde_json::to_value(payload).map_err(|error| {
339        ServerError::Internal(format!("Failed to serialize transcript payload: {error}"))
340    })?))
341}
342
343async fn get_reposlide_result(
344    State(state): State<AppState>,
345    Path(session_id): Path<String>,
346) -> Result<Json<serde_json::Value>, ServerError> {
347    let transcript = load_session_transcript(&state, &session_id).await?;
348    let session_cwd = load_session_cwd(&state, &session_id).await?;
349    let mut result = extract_reposlide_result(&transcript.messages);
350    if resolve_reposlide_deck_file(FsPath::new(&session_cwd), result.deck_path.as_deref()).is_some()
351    {
352        result.download_url = Some(build_reposlide_download_url(&session_id));
353    }
354
355    Ok(Json(
356        serde_json::to_value(RepoSlideResultPayload {
357            session_id,
358            result,
359            latest_event_kind: transcript.latest_event_kind,
360            source: transcript.source,
361        })
362        .map_err(|error| {
363            ServerError::Internal(format!(
364                "Failed to serialize RepoSlide result payload: {error}"
365            ))
366        })?,
367    ))
368}
369
370async fn download_reposlide_result(
371    State(state): State<AppState>,
372    Path(session_id): Path<String>,
373) -> Result<(HeaderMap, Vec<u8>), ServerError> {
374    let transcript = load_session_transcript(&state, &session_id).await?;
375    let session_cwd = load_session_cwd(&state, &session_id).await?;
376    let result = extract_reposlide_result(&transcript.messages);
377    let artifact =
378        resolve_reposlide_deck_file(FsPath::new(&session_cwd), result.deck_path.as_deref())
379            .ok_or_else(|| {
380                ServerError::NotFound("RepoSlide deck is not available for download".to_string())
381            })?;
382    let bytes = std::fs::read(&artifact.path).map_err(|error| {
383        ServerError::NotFound(format!("Failed to read RepoSlide deck: {error}"))
384    })?;
385
386    let mut headers = HeaderMap::new();
387    headers.insert("cache-control", "no-store".parse().unwrap());
388    headers.insert(
389        "content-type",
390        "application/vnd.openxmlformats-officedocument.presentationml.presentation"
391            .parse()
392            .unwrap(),
393    );
394    headers.insert(
395        "content-disposition",
396        format!("attachment; filename=\"{}\"", artifact.file_name)
397            .parse()
398            .unwrap(),
399    );
400
401    Ok((headers, bytes))
402}
403
404/// GET /api/sessions/{session_id}/context — Get hierarchical context for a session.
405///
406/// Returns the session's parent, children, siblings, and recent workspace sessions.
407/// Mirrors the Next.js `GET /api/sessions/[sessionId]/context` route.
408async fn get_session_context(
409    State(state): State<AppState>,
410    Path(session_id): Path<String>,
411) -> Result<Json<serde_json::Value>, ServerError> {
412    let service = SessionApplicationService::new(state);
413    let context = service.get_session_context(&session_id).await?;
414
415    Ok(Json(serde_json::json!({
416        "current": context.current,
417        "parent": context.parent,
418        "children": context.children,
419        "siblings": context.siblings,
420        "recentInWorkspace": context.recent_in_workspace,
421    })))
422}
423
424fn build_transcript_payload(
425    session_id: &str,
426    history: Vec<Value>,
427    traces: Vec<routa_core::trace::TraceRecord>,
428) -> SessionTranscriptPayload {
429    let history_messages = history_to_transcript_messages(&history);
430    let trace_messages = traces_to_transcript_messages(&traces);
431    let history_message_count = history_messages.len();
432    let trace_message_count = trace_messages.len();
433    let use_traces = trace_messages.len() > history_messages.len();
434    let preferred_messages = if use_traces {
435        trace_messages
436    } else {
437        history_messages
438    };
439    let latest_event_kind = history
440        .last()
441        .and_then(|entry| entry.get("update"))
442        .and_then(|update| update.get("sessionUpdate"))
443        .and_then(Value::as_str)
444        .map(str::to_string);
445
446    SessionTranscriptPayload {
447        session_id: session_id.to_string(),
448        history,
449        history_message_count,
450        trace_message_count,
451        source: if preferred_messages.is_empty() {
452            "empty"
453        } else if use_traces {
454            "traces"
455        } else {
456            "history"
457        },
458        latest_event_kind,
459        messages: preferred_messages,
460    }
461}
462
463async fn load_session_transcript(
464    state: &AppState,
465    session_id: &str,
466) -> Result<SessionTranscriptPayload, ServerError> {
467    let service = SessionApplicationService::new(state.clone());
468    let history = service.get_session_history(session_id, true).await?;
469    let cwd = std::env::current_dir()
470        .map_err(|error| ServerError::Internal(format!("Failed to get cwd: {error}")))?;
471    let traces = TraceReader::new(&cwd)
472        .query(&TraceQuery {
473            session_id: Some(session_id.to_string()),
474            ..TraceQuery::default()
475        })
476        .await
477        .map_err(|error| ServerError::Internal(format!("Failed to query traces: {error}")))?;
478
479    Ok(build_transcript_payload(session_id, history, traces))
480}
481
482async fn load_session_cwd(state: &AppState, session_id: &str) -> Result<String, ServerError> {
483    if let Some(session) = state.acp_manager.get_session(session_id).await {
484        return Ok(session.cwd);
485    }
486
487    state
488        .acp_session_store
489        .get(session_id)
490        .await?
491        .map(|session| session.cwd)
492        .ok_or_else(|| ServerError::NotFound("Session not found".to_string()))
493}
494
495fn extract_reposlide_result(messages: &[TranscriptMessage]) -> RepoSlideSessionResult {
496    let latest_assistant = messages
497        .iter()
498        .rev()
499        .find(|message| message.role == "assistant" && !message.content.trim().is_empty());
500
501    let Some(latest_assistant) = latest_assistant else {
502        return RepoSlideSessionResult {
503            status: "running",
504            deck_path: None,
505            download_url: None,
506            latest_assistant_message: None,
507            summary: None,
508            updated_at: None,
509        };
510    };
511
512    let deck_path = extract_pptx_path(&latest_assistant.content);
513    RepoSlideSessionResult {
514        status: if deck_path.is_some() {
515            "completed"
516        } else {
517            "running"
518        },
519        deck_path,
520        download_url: None,
521        latest_assistant_message: Some(latest_assistant.content.clone()),
522        summary: Some(summarize_reposlide_content(&latest_assistant.content)),
523        updated_at: Some(latest_assistant.timestamp.clone()),
524    }
525}
526
527fn extract_pptx_path(content: &str) -> Option<String> {
528    let pattern = Regex::new(r#"((?:/|[A-Za-z]:\\)[^\s"'`]+?\.pptx)\b"#).ok()?;
529    pattern
530        .captures(content)
531        .and_then(|captures| captures.get(1))
532        .map(|match_value| match_value.as_str().to_string())
533}
534
535fn summarize_reposlide_content(content: &str) -> String {
536    content
537        .lines()
538        .map(str::trim_end)
539        .filter(|line| !line.trim().is_empty())
540        .take(12)
541        .collect::<Vec<_>>()
542        .join("\n")
543}
544
545fn build_reposlide_download_url(session_id: &str) -> String {
546    format!(
547        "/api/sessions/{}/reposlide-result/download",
548        urlencoding::encode(session_id)
549    )
550}
551
552#[derive(Debug)]
553struct RepoSlideDeckFile {
554    path: PathBuf,
555    file_name: String,
556}
557
558fn resolve_reposlide_deck_file(
559    session_cwd: &FsPath,
560    deck_path: Option<&str>,
561) -> Option<RepoSlideDeckFile> {
562    let deck_path = deck_path?;
563    let candidate = FsPath::new(deck_path);
564    if !candidate.is_absolute() {
565        return None;
566    }
567    if candidate
568        .extension()
569        .and_then(|value| value.to_str())
570        .map(|value| value.eq_ignore_ascii_case("pptx"))
571        != Some(true)
572    {
573        return None;
574    }
575
576    let absolute_path = std::fs::canonicalize(candidate).ok()?;
577    let metadata = std::fs::metadata(&absolute_path).ok()?;
578    if !metadata.is_file() {
579        return None;
580    }
581
582    let allowed_roots = [
583        std::fs::canonicalize(session_cwd).ok(),
584        std::fs::canonicalize(std::env::temp_dir()).ok(),
585    ];
586    if !allowed_roots
587        .iter()
588        .flatten()
589        .any(|root| is_within_root(&absolute_path, root))
590    {
591        return None;
592    }
593
594    Some(RepoSlideDeckFile {
595        file_name: absolute_path.file_name()?.to_string_lossy().to_string(),
596        path: absolute_path,
597    })
598}
599
600fn is_within_root(target_path: &FsPath, root_path: &FsPath) -> bool {
601    if target_path == root_path {
602        return true;
603    }
604
605    target_path.starts_with(root_path)
606}
607
608fn history_to_transcript_messages(history: &[Value]) -> Vec<TranscriptMessage> {
609    let mut messages = Vec::new();
610    let mut last_kind: Option<&str> = None;
611    let mut last_assistant_idx: Option<usize> = None;
612    let mut last_thought_idx: Option<usize> = None;
613
614    for (index, notification) in history.iter().enumerate() {
615        let Some(update) = notification.get("update").and_then(Value::as_object) else {
616            continue;
617        };
618        let Some(kind) = update.get("sessionUpdate").and_then(Value::as_str) else {
619            continue;
620        };
621        let timestamp = update
622            .get("timestamp")
623            .and_then(Value::as_str)
624            .map(str::to_string)
625            .unwrap_or_else(now_iso);
626        let fallback_id = notification
627            .get("eventId")
628            .and_then(Value::as_str)
629            .map(str::to_string)
630            .unwrap_or_else(|| format!("history-{kind}-{index}"));
631
632        match kind {
633            "user_message" => {
634                last_assistant_idx = None;
635                last_thought_idx = None;
636                if let Some(text) = update
637                    .get("content")
638                    .and_then(Value::as_object)
639                    .and_then(|content| content.get("text"))
640                    .and_then(Value::as_str)
641                {
642                    messages.push(TranscriptMessage {
643                        id: fallback_id,
644                        role: "user",
645                        content: text.to_string(),
646                        timestamp,
647                        tool_name: None,
648                        tool_status: None,
649                        tool_call_id: None,
650                        tool_raw_input: None,
651                        tool_raw_output: None,
652                        raw_data: None,
653                    });
654                }
655            }
656            "agent_message" | "agent_message_chunk" => {
657                if let Some(text) = update
658                    .get("content")
659                    .and_then(Value::as_object)
660                    .and_then(|content| content.get("text"))
661                    .and_then(Value::as_str)
662                {
663                    let normalized_text = if kind == "agent_message"
664                        || (kind == "agent_message_chunk"
665                            && last_kind != Some("agent_message_chunk"))
666                    {
667                        trim_leading_response_breaks(text)
668                    } else {
669                        text.to_string()
670                    };
671                    if kind == "agent_message_chunk" && last_kind == Some("agent_message_chunk") {
672                        if let Some(existing_idx) = last_assistant_idx {
673                            if let Some(existing) = messages.get_mut(existing_idx) {
674                                existing.content.push_str(text);
675                            }
676                        } else {
677                            messages.push(TranscriptMessage {
678                                id: fallback_id,
679                                role: "assistant",
680                                content: normalized_text,
681                                timestamp,
682                                tool_name: None,
683                                tool_status: None,
684                                tool_call_id: None,
685                                tool_raw_input: None,
686                                tool_raw_output: None,
687                                raw_data: None,
688                            });
689                            last_assistant_idx = Some(messages.len() - 1);
690                        }
691                    } else {
692                        messages.push(TranscriptMessage {
693                            id: fallback_id,
694                            role: "assistant",
695                            content: normalized_text,
696                            timestamp,
697                            tool_name: None,
698                            tool_status: None,
699                            tool_call_id: None,
700                            tool_raw_input: None,
701                            tool_raw_output: None,
702                            raw_data: None,
703                        });
704                        last_assistant_idx = Some(messages.len() - 1);
705                    }
706                    last_thought_idx = None;
707                }
708            }
709            "agent_thought" | "agent_thought_chunk" => {
710                if let Some(text) = update
711                    .get("content")
712                    .and_then(Value::as_object)
713                    .and_then(|content| content.get("text"))
714                    .and_then(Value::as_str)
715                {
716                    let normalized_text = if kind == "agent_thought"
717                        || (kind == "agent_thought_chunk"
718                            && last_kind != Some("agent_thought_chunk"))
719                    {
720                        trim_leading_response_breaks(text)
721                    } else {
722                        text.to_string()
723                    };
724                    if kind == "agent_thought_chunk" && last_kind == Some("agent_thought_chunk") {
725                        if let Some(existing_idx) = last_thought_idx {
726                            if let Some(existing) = messages.get_mut(existing_idx) {
727                                existing.content.push_str(text);
728                            }
729                        } else {
730                            messages.push(TranscriptMessage {
731                                id: fallback_id,
732                                role: "thought",
733                                content: normalized_text,
734                                timestamp,
735                                tool_name: None,
736                                tool_status: None,
737                                tool_call_id: None,
738                                tool_raw_input: None,
739                                tool_raw_output: None,
740                                raw_data: None,
741                            });
742                            last_thought_idx = Some(messages.len() - 1);
743                        }
744                    } else {
745                        messages.push(TranscriptMessage {
746                            id: fallback_id,
747                            role: "thought",
748                            content: normalized_text,
749                            timestamp,
750                            tool_name: None,
751                            tool_status: None,
752                            tool_call_id: None,
753                            tool_raw_input: None,
754                            tool_raw_output: None,
755                            raw_data: None,
756                        });
757                        last_thought_idx = Some(messages.len() - 1);
758                    }
759                    last_assistant_idx = None;
760                }
761            }
762            "tool_call" | "tool_call_update" => {
763                last_assistant_idx = None;
764                last_thought_idx = None;
765                let tool_name = update
766                    .get("title")
767                    .and_then(Value::as_str)
768                    .or_else(|| update.get("toolName").and_then(Value::as_str))
769                    .unwrap_or("Tool");
770                let status = update
771                    .get("status")
772                    .and_then(Value::as_str)
773                    .unwrap_or("running");
774                let raw_input = update.get("rawInput").cloned();
775                let raw_output = update.get("rawOutput").cloned();
776                let content = if let Some(raw_input) = raw_input.as_ref() {
777                    format!(
778                        "Input:\n{}",
779                        serde_json::to_string_pretty(raw_input).unwrap_or_default()
780                    )
781                } else {
782                    tool_name.to_string()
783                };
784
785                messages.push(TranscriptMessage {
786                    id: update
787                        .get("toolCallId")
788                        .and_then(Value::as_str)
789                        .map(str::to_string)
790                        .unwrap_or(fallback_id),
791                    role: "tool",
792                    content,
793                    timestamp,
794                    tool_name: Some(tool_name.to_string()),
795                    tool_status: Some(status.to_string()),
796                    tool_call_id: update
797                        .get("toolCallId")
798                        .and_then(Value::as_str)
799                        .map(str::to_string),
800                    tool_raw_input: raw_input,
801                    tool_raw_output: raw_output,
802                    raw_data: Some(Value::Object(update.clone())),
803                });
804            }
805            "plan" => {
806                last_assistant_idx = None;
807                last_thought_idx = None;
808                let content = update
809                    .get("plan")
810                    .and_then(Value::as_str)
811                    .map(str::to_string)
812                    .or_else(|| {
813                        update
814                            .get("entries")
815                            .and_then(Value::as_array)
816                            .map(|entries| {
817                                entries
818                                    .iter()
819                                    .filter_map(Value::as_object)
820                                    .map(|entry| {
821                                        let status = entry
822                                            .get("status")
823                                            .and_then(Value::as_str)
824                                            .unwrap_or("pending");
825                                        let body = entry
826                                            .get("content")
827                                            .and_then(Value::as_str)
828                                            .unwrap_or_default();
829                                        format!("[{status}] {body}")
830                                    })
831                                    .collect::<Vec<_>>()
832                                    .join("\n")
833                            })
834                    })
835                    .unwrap_or_default();
836
837                if !content.is_empty() {
838                    messages.push(TranscriptMessage {
839                        id: fallback_id,
840                        role: "plan",
841                        content,
842                        timestamp,
843                        tool_name: None,
844                        tool_status: None,
845                        tool_call_id: None,
846                        tool_raw_input: None,
847                        tool_raw_output: None,
848                        raw_data: Some(Value::Object(update.clone())),
849                    });
850                }
851            }
852            _ => {
853                last_assistant_idx = None;
854                last_thought_idx = None;
855            }
856        }
857
858        last_kind = Some(kind);
859    }
860
861    messages
862}
863
864fn trim_leading_response_breaks(text: &str) -> String {
865    text.trim_start_matches(['\r', '\n']).to_string()
866}
867
868fn traces_to_transcript_messages(
869    traces: &[routa_core::trace::TraceRecord],
870) -> Vec<TranscriptMessage> {
871    let mut messages = Vec::new();
872    let mut traces = traces.to_vec();
873    traces.sort_by_key(|trace| trace.timestamp);
874
875    for trace in traces {
876        match trace.event_type {
877            TraceEventType::UserMessage => {
878                if let Some(content) = trace_conversation_text(&trace) {
879                    messages.push(TranscriptMessage {
880                        id: trace.id,
881                        role: "user",
882                        content,
883                        timestamp: trace.timestamp.to_rfc3339(),
884                        tool_name: None,
885                        tool_status: None,
886                        tool_call_id: None,
887                        tool_raw_input: None,
888                        tool_raw_output: None,
889                        raw_data: None,
890                    });
891                }
892            }
893            TraceEventType::AgentMessage => {
894                if let Some(content) = trace_conversation_text(&trace) {
895                    messages.push(TranscriptMessage {
896                        id: trace.id,
897                        role: "assistant",
898                        content,
899                        timestamp: trace.timestamp.to_rfc3339(),
900                        tool_name: None,
901                        tool_status: None,
902                        tool_call_id: None,
903                        tool_raw_input: None,
904                        tool_raw_output: None,
905                        raw_data: None,
906                    });
907                }
908            }
909            TraceEventType::AgentThought => {
910                if let Some(content) = trace_conversation_text(&trace) {
911                    messages.push(TranscriptMessage {
912                        id: trace.id,
913                        role: "thought",
914                        content,
915                        timestamp: trace.timestamp.to_rfc3339(),
916                        tool_name: None,
917                        tool_status: None,
918                        tool_call_id: None,
919                        tool_raw_input: None,
920                        tool_raw_output: None,
921                        raw_data: None,
922                    });
923                }
924            }
925            TraceEventType::ToolCall | TraceEventType::ToolResult => {
926                if let Some(tool) = trace.tool.as_ref() {
927                    messages.push(TranscriptMessage {
928                        id: tool
929                            .tool_call_id
930                            .clone()
931                            .unwrap_or_else(|| trace.id.clone()),
932                        role: "tool",
933                        content: tool
934                            .output
935                            .as_ref()
936                            .map(format_json_value)
937                            .or_else(|| tool.input.as_ref().map(format_json_value))
938                            .unwrap_or_else(|| tool.name.clone()),
939                        timestamp: trace.timestamp.to_rfc3339(),
940                        tool_name: Some(tool.name.clone()),
941                        tool_status: tool.status.clone(),
942                        tool_call_id: tool.tool_call_id.clone(),
943                        tool_raw_input: tool.input.clone(),
944                        tool_raw_output: tool.output.clone(),
945                        raw_data: None,
946                    });
947                }
948            }
949            TraceEventType::SessionStart | TraceEventType::SessionEnd => {}
950        }
951    }
952
953    messages
954}
955
956fn trace_conversation_text(trace: &routa_core::trace::TraceRecord) -> Option<String> {
957    trace
958        .conversation
959        .as_ref()
960        .and_then(|conversation| conversation.full_content.clone())
961        .or_else(|| {
962            trace
963                .conversation
964                .as_ref()
965                .and_then(|conversation| conversation.content_preview.clone())
966        })
967}
968
969fn format_json_value(value: &Value) -> String {
970    match value {
971        Value::String(text) => text.clone(),
972        _ => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()),
973    }
974}
975
976fn now_iso() -> String {
977    DateTime::<Utc>::from(std::time::SystemTime::now()).to_rfc3339()
978}
979
980#[cfg(test)]
981mod tests {
982    use crate::application::sessions::consolidate_message_history;
983    use routa_core::trace::{Contributor, TraceEventType, TraceRecord};
984    use serde_json::json;
985
986    use super::{
987        build_transcript_payload, extract_reposlide_result, history_to_transcript_messages,
988        resolve_reposlide_deck_file, TranscriptMessage,
989    };
990
991    #[test]
992    fn consolidate_message_history_merges_chunks_for_same_session() {
993        let notifications = vec![
994            json!({"sessionId":"s1","update": {"sessionUpdate":"agent_message_chunk","content": {"text":"Hel"}}}),
995            json!({"sessionId":"s1","update": {"sessionUpdate":"agent_message_chunk","content": {"text":"lo"}}}),
996            json!({"sessionId":"s1","update": {"sessionUpdate":"agent_done","content": {"text":"!"}}}),
997        ];
998
999        let merged = consolidate_message_history(notifications);
1000
1001        assert_eq!(merged.len(), 2);
1002        assert_eq!(merged[0]["sessionId"].as_str(), Some("s1"));
1003        assert_eq!(
1004            merged[0]["update"]["sessionUpdate"].as_str(),
1005            Some("agent_message")
1006        );
1007        assert_eq!(
1008            merged[0]["update"]["content"]["text"].as_str(),
1009            Some("Hello")
1010        );
1011    }
1012
1013    #[test]
1014    fn consolidate_message_history_handles_session_switches() {
1015        let notifications = vec![
1016            json!({"sessionId":"s1","update": {"sessionUpdate":"agent_message_chunk","content": {"text":"A"}}}),
1017            json!({"sessionId":"s2","update": {"sessionUpdate":"agent_message_chunk","content": {"text":"B"}}}),
1018            json!({"sessionId":"s1","update": {"sessionUpdate":"agent_message_chunk","content": {"text":"C"}}}),
1019        ];
1020
1021        let merged = consolidate_message_history(notifications);
1022
1023        assert_eq!(merged.len(), 3);
1024        assert_eq!(merged[0]["update"]["content"]["text"].as_str(), Some("A"));
1025        assert_eq!(merged[1]["update"]["content"]["text"].as_str(), Some("B"));
1026        assert_eq!(merged[2]["update"]["content"]["text"].as_str(), Some("C"));
1027    }
1028
1029    #[test]
1030    fn transcript_payload_prefers_history_messages_when_richer() {
1031        let history = vec![
1032            json!({"sessionId":"s1","update":{"sessionUpdate":"user_message","content":{"text":"Build it"}}}),
1033            json!({"sessionId":"s1","update":{"sessionUpdate":"agent_message","content":{"text":"Working on it"}}}),
1034            json!({"sessionId":"s1","update":{"sessionUpdate":"tool_call_update","title":"Read File","status":"completed","toolCallId":"tool-1","rawInput":{"path":"src/lib.rs"}}}),
1035        ];
1036        let traces = vec![TraceRecord::new(
1037            "s1",
1038            TraceEventType::AgentMessage,
1039            Contributor::new("opencode", None),
1040        )];
1041
1042        let payload = build_transcript_payload("s1", history.clone(), traces);
1043
1044        assert_eq!(payload.session_id, "s1");
1045        assert_eq!(payload.source, "history");
1046        assert_eq!(payload.history, history);
1047        assert_eq!(payload.history_message_count, 3);
1048        assert_eq!(payload.trace_message_count, 0);
1049        assert_eq!(payload.messages.len(), 3);
1050        assert_eq!(payload.messages[0].role, "user");
1051        assert_eq!(payload.messages[1].role, "assistant");
1052        assert_eq!(payload.messages[2].role, "tool");
1053        assert_eq!(
1054            payload.latest_event_kind.as_deref(),
1055            Some("tool_call_update")
1056        );
1057    }
1058
1059    #[test]
1060    fn history_transcript_merges_contiguous_thought_chunks() {
1061        let messages = history_to_transcript_messages(&[
1062            json!({"sessionId":"s1","update":{"sessionUpdate":"agent_thought_chunk","content":{"text":"The"}}}),
1063            json!({"sessionId":"s1","update":{"sessionUpdate":"agent_thought_chunk","content":{"text":" user"}}}),
1064            json!({"sessionId":"s1","update":{"sessionUpdate":"agent_thought_chunk","content":{"text":" said hi"}}}),
1065        ]);
1066
1067        assert_eq!(messages.len(), 1);
1068        assert_eq!(messages[0].role, "thought");
1069        assert_eq!(messages[0].content, "The user said hi");
1070    }
1071
1072    #[test]
1073    fn history_transcript_breaks_thought_group_on_non_chunk_update() {
1074        let messages = history_to_transcript_messages(&[
1075            json!({"sessionId":"s1","update":{"sessionUpdate":"agent_thought_chunk","content":{"text":"The"}}}),
1076            json!({"sessionId":"s1","update":{"sessionUpdate":"usage_update","used":1,"size":2}}),
1077            json!({"sessionId":"s1","update":{"sessionUpdate":"agent_thought_chunk","content":{"text":" user"}}}),
1078        ]);
1079
1080        assert_eq!(messages.len(), 2);
1081        assert_eq!(messages[0].content, "The");
1082        assert_eq!(messages[1].content, " user");
1083    }
1084
1085    #[test]
1086    fn history_transcript_merges_contiguous_agent_message_chunks() {
1087        let messages = history_to_transcript_messages(&[
1088            json!({"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":{"text":"hello"}}}),
1089            json!({"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":{"text":" world"}}}),
1090        ]);
1091
1092        assert_eq!(messages.len(), 1);
1093        assert_eq!(messages[0].role, "assistant");
1094        assert_eq!(messages[0].content, "hello world");
1095    }
1096
1097    #[test]
1098    fn history_transcript_trims_leading_breaks_for_new_assistant_message() {
1099        let messages = history_to_transcript_messages(&[
1100            json!({"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":{"text":"\n\nHi!"}}}),
1101            json!({"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":{"text":" How can I help?"}}}),
1102        ]);
1103
1104        assert_eq!(messages.len(), 1);
1105        assert_eq!(messages[0].role, "assistant");
1106        assert_eq!(messages[0].content, "Hi! How can I help?");
1107    }
1108
1109    #[test]
1110    fn history_transcript_trims_leading_breaks_for_new_thought_message() {
1111        let messages = history_to_transcript_messages(&[
1112            json!({"sessionId":"s1","update":{"sessionUpdate":"agent_thought_chunk","content":{"text":"\nThe"}}}),
1113            json!({"sessionId":"s1","update":{"sessionUpdate":"agent_thought_chunk","content":{"text":" user"}}}),
1114        ]);
1115
1116        assert_eq!(messages.len(), 1);
1117        assert_eq!(messages[0].role, "thought");
1118        assert_eq!(messages[0].content, "The user");
1119    }
1120
1121    #[test]
1122    fn extract_reposlide_result_detects_completed_deck() {
1123        let messages = vec![TranscriptMessage {
1124            id: "m1".to_string(),
1125            role: "assistant",
1126            content: "Saved PPTX to /tmp/reposlide/demo-deck.pptx\nSlide outline:\n- Intro"
1127                .to_string(),
1128            timestamp: "2026-04-01T03:00:00Z".to_string(),
1129            tool_name: None,
1130            tool_status: None,
1131            tool_call_id: None,
1132            tool_raw_input: None,
1133            tool_raw_output: None,
1134            raw_data: None,
1135        }];
1136
1137        let result = extract_reposlide_result(&messages);
1138        assert_eq!(result.status, "completed");
1139        assert_eq!(
1140            result.deck_path.as_deref(),
1141            Some("/tmp/reposlide/demo-deck.pptx")
1142        );
1143        assert!(result.download_url.is_none());
1144        assert!(result.summary.unwrap_or_default().contains("Slide outline"));
1145    }
1146
1147    #[test]
1148    fn extract_reposlide_result_defaults_to_running_without_assistant_output() {
1149        let result = extract_reposlide_result(&[]);
1150        assert_eq!(result.status, "running");
1151        assert!(result.deck_path.is_none());
1152    }
1153
1154    #[test]
1155    fn resolve_reposlide_deck_file_allows_temp_pptx_artifacts() {
1156        let session_dir = tempfile::tempdir().unwrap();
1157        let output_dir = tempfile::tempdir().unwrap();
1158        let deck_path = output_dir.path().join("demo-deck.pptx");
1159        std::fs::write(&deck_path, b"demo").unwrap();
1160
1161        let artifact = resolve_reposlide_deck_file(session_dir.path(), deck_path.to_str());
1162
1163        assert_eq!(
1164            artifact.as_ref().map(|value| value.file_name.as_str()),
1165            Some("demo-deck.pptx")
1166        );
1167    }
1168
1169    #[test]
1170    fn resolve_reposlide_deck_file_rejects_paths_outside_session_and_temp_roots() {
1171        let session_dir = tempfile::tempdir().unwrap();
1172        let repo_root = std::env::current_dir().unwrap();
1173        let external_dir = tempfile::Builder::new()
1174            .prefix("reposlide-external-")
1175            .tempdir_in(&repo_root)
1176            .unwrap();
1177        let deck_path = external_dir.path().join("demo-deck.pptx");
1178        std::fs::write(&deck_path, b"demo").unwrap();
1179
1180        let artifact = resolve_reposlide_deck_file(session_dir.path(), deck_path.to_str());
1181
1182        assert!(artifact.is_none());
1183    }
1184}