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
110async 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
130async 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
151async 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 let in_memory_found = state
164 .acp_manager
165 .rename_session(&session_id, name)
166 .await
167 .is_some();
168
169 state.acp_session_store.rename(&session_id, name).await?;
171
172 if !in_memory_found {
174 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
183async fn delete_session(
185 State(state): State<AppState>,
186 Path(session_id): Path<String>,
187) -> Result<Json<serde_json::Value>, ServerError> {
188 let in_memory_found = state
190 .acp_manager
191 .delete_session(&session_id)
192 .await
193 .is_some();
194
195 state.acp_session_store.delete(&session_id).await?;
197
198 if !in_memory_found {
200 }
204
205 Ok(Json(serde_json::json!({ "ok": true })))
206}
207
208async fn disconnect_session(
213 State(state): State<AppState>,
214 Path(session_id): Path<String>,
215) -> Result<Json<serde_json::Value>, ServerError> {
216 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 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 state.acp_manager.kill_session(&session_id).await;
236
237 Ok(Json(serde_json::json!({ "ok": true })))
238}
239
240async fn fork_session(
244 State(state): State<AppState>,
245 Path(session_id): Path<String>,
246) -> Result<Json<serde_json::Value>, ServerError> {
247 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
301async 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
318async 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
404async 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}