use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::canonical::{
CanonicalSession, ImportedSession, SessionArtifact, SessionEvent, SessionEventKind,
};
use crate::format;
use crate::provider::ProviderSessionSummary;
use crate::storage::session_state::{self, SessionStateStore};
use crate::{provider, providers};
pub mod manager;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionListParams {
pub all: bool,
pub providers: Vec<String>,
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionGroup {
pub provider_id: String,
pub provider_name: String,
pub sessions: Vec<SessionItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionItem {
pub session_id: String,
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_title: Option<String>,
#[serde(default)]
pub hidden: bool,
#[serde(default)]
pub pinned: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub preferred_targets: Vec<String>,
pub project_dir: Option<String>,
pub last_active_at: Option<i64>,
pub source_path: Option<String>,
pub provider_id: String,
pub message_count: Option<usize>,
pub size_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionDetailView {
pub provider_id: String,
pub provider_name: String,
pub session_id: String,
pub canonical_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_dir: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_active_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resume_command: Option<String>,
pub local_state: session_state::ResolvedLocalSessionState,
pub event_count: usize,
pub message_count: usize,
pub artifact_count: usize,
pub events: Vec<SessionEvent>,
pub artifacts: Vec<SessionArtifact>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct ResolvedSessionState {
native_title: Option<String>,
local: session_state::ResolvedLocalSessionState,
}
impl ResolvedSessionState {
fn resolved_title(&self) -> Option<&str> {
self.local
.display_title
.as_deref()
.or(self.native_title.as_deref())
}
}
impl From<(&ProviderSessionSummary, &str)> for SessionItem {
fn from((meta, provider_id): (&ProviderSessionSummary, &str)) -> Self {
Self {
session_id: meta.session_id.clone(),
title: meta.title.clone(),
native_title: meta.title.clone(),
display_title: None,
hidden: false,
pinned: false,
preferred_targets: Vec::new(),
project_dir: meta.project_dir.clone(),
last_active_at: meta.last_active_at,
source_path: meta.source_path.clone(),
provider_id: provider_id.to_string(),
message_count: None,
size_bytes: None,
}
}
}
pub fn resolve_providers(filter: &[String]) -> Vec<String> {
if filter.is_empty() {
providers::all_provider_ids()
.iter()
.map(|s| s.to_string())
.collect()
} else {
filter.to_vec()
}
}
pub fn list_sessions(params: &SessionListParams) -> Result<Vec<SessionGroup>> {
let provider_ids = resolve_providers(¶ms.providers);
let session_states = session_state::load_state_store().unwrap_or_default();
let mut groups = Vec::new();
for pid in &provider_ids {
let prov = match providers::find_provider(pid) {
Some(p) => p,
None => continue,
};
let capabilities = prov.capabilities();
if !capabilities.scan {
continue;
}
let sessions = prov.scan_sessions()?;
let mut filtered: Vec<SessionItem> = if params.all {
sessions
.iter()
.map(|s| {
enrich_session_item(
prov.as_ref(),
capabilities,
pid.as_str(),
s,
&session_states,
)
})
.collect()
} else {
let cwd = params.cwd.as_deref().unwrap_or("");
sessions
.iter()
.filter(|s| s.project_dir.as_ref().map(|d| d == cwd).unwrap_or(false))
.map(|s| {
enrich_session_item(
prov.as_ref(),
capabilities,
pid.as_str(),
s,
&session_states,
)
})
.collect()
};
filtered.sort_by_key(|s| {
(
std::cmp::Reverse(s.pinned),
std::cmp::Reverse(s.last_active_at),
)
});
if !filtered.is_empty() {
groups.push(SessionGroup {
provider_id: pid.clone(),
provider_name: prov.name().to_string(),
sessions: filtered,
});
}
}
Ok(groups)
}
fn enrich_session_item(
provider: &dyn provider::Provider,
capabilities: provider::ProviderCapabilities,
provider_id: &str,
meta: &ProviderSessionSummary,
session_states: &SessionStateStore,
) -> SessionItem {
let mut item = SessionItem::from((meta, provider_id));
let state = resolve_session_state(
provider_id,
&meta.session_id,
meta.title.clone(),
meta.project_dir.as_deref(),
session_states,
);
apply_session_item_state(&mut item, &state);
item.size_bytes = provider
.session_size(&meta.session_id)
.ok()
.filter(|size| *size > 0)
.or_else(|| {
meta.source_path.as_deref().and_then(|path| {
std::fs::metadata(path)
.ok()
.filter(|metadata| metadata.is_file())
.map(|metadata| metadata.len())
})
});
if capabilities.import {
if let Some(source_path) = meta.source_path.as_deref() {
item.message_count = provider
.import_session(source_path)
.ok()
.map(|imported| session_message_count(&imported.session));
}
}
item
}
pub fn get_canonical_session(provider_id: &str, session_id: &str) -> Result<ImportedSession> {
let prov = providers::find_provider(provider_id)
.with_context(|| format!("Unknown provider: {}", provider_id))?;
let capabilities = prov.capabilities();
if !capabilities.scan || !capabilities.import {
anyhow::bail!(
"Provider does not support loading sessions: {}",
provider_id
);
}
let meta = prov
.scan_sessions()?
.into_iter()
.find(|session| session.session_id == session_id)
.with_context(|| format!("Session not found: {}", session_id))?;
load_canonical_session_from_meta(prov.as_ref(), provider_id, meta)
}
pub fn get_session_detail_view(provider_id: &str, session_id: &str) -> Result<SessionDetailView> {
let prov = providers::find_provider(provider_id)
.with_context(|| format!("Unknown provider: {}", provider_id))?;
let capabilities = prov.capabilities();
if !capabilities.scan || !capabilities.import {
anyhow::bail!(
"Provider does not support loading sessions: {}",
provider_id
);
}
let meta = prov
.scan_sessions()?
.into_iter()
.find(|session| session.session_id == session_id)
.with_context(|| format!("Session not found: {}", session_id))?;
let source_path = meta.source_path.clone();
let workspace_dir = meta.project_dir.clone();
let native_title = meta.title.clone();
let imported = load_canonical_session_from_meta(prov.as_ref(), provider_id, meta)?;
let local_state =
get_resolved_local_session_state(provider_id, session_id, workspace_dir.as_deref());
Ok(build_session_detail_view(
provider_id,
prov.name(),
session_id,
source_path,
prov.resume_command(session_id),
native_title,
local_state,
imported,
))
}
pub fn get_resolved_local_session_state(
provider_id: &str,
session_id: &str,
workspace_dir: Option<&str>,
) -> session_state::ResolvedLocalSessionState {
let session_states = session_state::load_state_store().unwrap_or_default();
session_state::resolve_session_state(&session_states, provider_id, session_id, workspace_dir)
}
fn build_session_detail_view(
provider_id: &str,
provider_name: &str,
session_id: &str,
source_path: Option<String>,
resume_command: Option<String>,
native_title: Option<String>,
local_state: session_state::ResolvedLocalSessionState,
imported: ImportedSession,
) -> SessionDetailView {
let session = imported.session;
let display_title = local_state.display_title.clone();
let title = display_title
.clone()
.or_else(|| native_title.clone())
.or_else(|| session.identity.source_title.clone());
let message_count = session_message_count(&session);
let source_path = source_path.or_else(|| session.provenance.primary_source.source_path.clone());
SessionDetailView {
provider_id: provider_id.to_string(),
provider_name: provider_name.to_string(),
session_id: session_id.to_string(),
canonical_id: session.identity.canonical_id.clone(),
title,
native_title,
display_title,
workspace_dir: session.context.workspace_dir.clone(),
created_at: session.context.created_at,
last_active_at: session.context.last_active_at,
source_path,
resume_command,
local_state,
event_count: session.events.len(),
message_count,
artifact_count: session.artifacts.len(),
events: session.events,
artifacts: session.artifacts,
}
}
fn resolve_session_state(
provider_id: &str,
session_id: &str,
native_title: Option<String>,
workspace_dir: Option<&str>,
session_states: &SessionStateStore,
) -> ResolvedSessionState {
ResolvedSessionState {
native_title,
local: session_state::resolve_session_state(
session_states,
provider_id,
session_id,
workspace_dir,
),
}
}
fn apply_session_item_state(item: &mut SessionItem, state: &ResolvedSessionState) {
item.native_title = state.native_title.clone();
item.display_title = state.local.display_title.clone();
item.title = state.resolved_title().map(str::to_string);
item.hidden = state.local.hidden;
item.pinned = state.local.pinned;
item.preferred_targets = state.local.preferred_targets.clone();
}
fn session_message_count(session: &CanonicalSession) -> usize {
session
.events
.iter()
.filter(|event| !matches!(event.kind, SessionEventKind::Lifecycle))
.count()
}
fn load_canonical_session_from_meta(
provider: &dyn provider::Provider,
provider_id: &str,
meta: ProviderSessionSummary,
) -> Result<ImportedSession> {
let source_path = meta
.source_path
.as_deref()
.context("Session has no source path")?;
let mut imported = provider.import_session(source_path)?;
if imported.session.identity.source_title.is_none() {
imported.session.identity.source_title = meta.title.clone();
}
if imported.session.context.workspace_dir.is_none() {
imported.session.context.workspace_dir = meta.project_dir.clone();
}
if imported.session.context.last_active_at.is_none() {
imported.session.context.last_active_at = meta
.last_active_at
.and_then(chrono::DateTime::from_timestamp_millis);
}
if imported
.session
.provenance
.aliases
.iter()
.all(|alias| alias.provider_id != provider_id || alias.session_id != meta.session_id)
{
imported
.session
.provenance
.aliases
.push(crate::canonical::ProviderSessionRef {
provider_id: provider_id.to_string(),
session_id: meta.session_id,
source_path: meta.source_path,
});
}
Ok(imported)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportParams {
pub provider: String,
pub session_id: String,
pub output_prefix: Option<String>,
pub format: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportResult {
pub files: Vec<String>,
}
pub fn export_session(params: &ExportParams) -> Result<ExportResult> {
let imported = get_canonical_session(¶ms.provider, ¶ms.session_id)?;
let prefix = params
.output_prefix
.as_deref()
.unwrap_or(¶ms.session_id);
let mut files = Vec::new();
let write_morph = params.format == "morph" || params.format == "both";
let write_json = params.format == "json" || params.format == "both";
let write_markdown = params.format == "md" || params.format == "markdown";
let write_html = params.format == "html";
if !write_morph && !write_json && !write_markdown && !write_html {
anyhow::bail!(
"Unsupported format: {}. Use 'json', 'md', 'html', 'morph', or 'both'",
params.format
);
}
if write_morph {
let path = PathBuf::from(format!("{}.morph", prefix));
format::write_session(&path, &imported.session)?;
files.push(path.display().to_string());
}
if write_json {
let path = PathBuf::from(format!("{}.json", prefix));
let json = serde_json::to_string_pretty(&imported.session)?;
std::fs::write(&path, json)?;
files.push(path.display().to_string());
}
if write_markdown {
let path = PathBuf::from(format!("{}.md", prefix));
format::write_markdown(&path, &imported.session)?;
files.push(path.display().to_string());
}
if write_html {
let path = PathBuf::from(format!("{}.html", prefix));
format::write_html(&path, &imported.session)?;
files.push(path.display().to_string());
}
Ok(ExportResult { files })
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportParams {
pub provider: String,
pub file_or_id: String,
pub to_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportResult {
pub provider_name: String,
pub new_session_id: String,
pub resume_command: Option<String>,
}
pub fn import_session(params: &ImportParams) -> Result<ImportResult> {
let cwd = std::env::current_dir()?;
let target_dir = if let Some(dir) = ¶ms.to_dir {
let p = Path::new(dir);
if !p.exists() {
anyhow::bail!("Target directory does not exist: {}", dir);
}
p.canonicalize()?
} else {
cwd
};
let session = if params.file_or_id.ends_with(".morph")
|| params.file_or_id.ends_with(".json")
|| params.file_or_id.ends_with(".md")
|| params.file_or_id.ends_with(".html")
{
let path = Path::new(¶ms.file_or_id);
if params.file_or_id.ends_with(".morph") {
format::read_session(path)?
} else if params.file_or_id.ends_with(".json") {
let json = std::fs::read_to_string(path)?;
serde_json::from_str(&json)?
} else if params.file_or_id.ends_with(".md") {
format::read_markdown(path)?
} else {
format::read_html(path)?
}
} else {
get_canonical_session(¶ms.provider, ¶ms.file_or_id)?.session
};
let target_prov = providers::find_provider(¶ms.provider)
.with_context(|| format!("Target provider not available: {}", params.provider))?;
let target_capabilities = target_prov.capabilities();
if !target_capabilities.export {
anyhow::bail!(
"Provider does not support writing sessions: {}",
params.provider
);
}
let exported = target_prov.export_session(&session, &target_dir)?;
Ok(ImportResult {
provider_name: target_prov.name().to_string(),
new_session_id: exported.session_id,
resume_command: exported.resume_command,
})
}
pub fn delete_session(provider_id: &str, session_id: &str) -> Result<()> {
let prov = providers::find_provider(provider_id)
.with_context(|| format!("Unknown provider: {}", provider_id))?;
if !prov.capabilities().delete {
anyhow::bail!(
"Provider does not support deleting sessions: {}",
provider_id
);
}
prov.delete_session(session_id)?;
session_state::remove_session(provider_id, session_id)?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameResult {
pub provider_name: String,
pub session_id: String,
pub display_title: String,
pub native_updated: bool,
pub warning: Option<String>,
}
pub fn rename_session(
provider_id: &str,
session_id: &str,
new_title: &str,
) -> Result<RenameResult> {
let new_title = new_title.trim();
if new_title.is_empty() {
anyhow::bail!("Session title cannot be empty");
}
let prov = providers::find_provider(provider_id)
.with_context(|| format!("Unknown provider: {}", provider_id))?;
let capabilities = prov.capabilities();
if capabilities.scan {
let exists = prov
.scan_sessions()?
.into_iter()
.any(|session| session.session_id == session_id);
if !exists {
anyhow::bail!("Session not found: {}", session_id);
}
}
let mut warning = None;
let native_updated = if capabilities.rename {
match prov.rename_session(session_id, new_title) {
Ok(()) => true,
Err(e) => {
warning = Some(format!(
"Provider rename failed; memorph display title was saved: {e}"
));
false
}
}
} else {
warning = Some(format!(
"Provider does not support native rename; memorph display title was saved: {}",
provider_id
));
false
};
session_state::set_display_title(provider_id, session_id, new_title)?;
Ok(RenameResult {
provider_name: prov.name().to_string(),
session_id: session_id.to_string(),
display_title: new_title.to_string(),
native_updated,
warning,
})
}
pub fn update_session_local_state(
provider_id: &str,
session_id: &str,
update: &session_state::SessionLocalStateUpdate,
) -> Result<session_state::ResolvedLocalSessionState> {
let prov = providers::find_provider(provider_id)
.with_context(|| format!("Unknown provider: {}", provider_id))?;
let capabilities = prov.capabilities();
if capabilities.scan {
let exists = prov
.scan_sessions()?
.into_iter()
.any(|session| session.session_id == session_id);
if !exists {
anyhow::bail!("Session not found: {}", session_id);
}
}
session_state::update_session_state(provider_id, session_id, update)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwitchParams {
pub from: String,
pub to: String,
pub session_id: Option<String>,
pub to_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwitchResult {
pub from_name: String,
pub to_name: String,
pub source_session_id: String,
pub target_session_id: String,
pub resume_command: Option<String>,
}
pub fn switch_session(params: &SwitchParams) -> Result<SwitchResult> {
let cwd = std::env::current_dir()?;
let target_dir = if let Some(dir) = ¶ms.to_dir {
let p = Path::new(dir);
if !p.exists() {
anyhow::bail!("Target directory does not exist: {}", dir);
}
p.canonicalize()?
} else {
cwd.clone()
};
let source_prov = providers::find_provider(¶ms.from)
.with_context(|| format!("Unknown source provider: {}", params.from))?;
let source_capabilities = source_prov.capabilities();
if !source_capabilities.scan || !source_capabilities.import {
anyhow::bail!(
"Source provider does not support reading sessions: {}",
params.from
);
}
let sessions = source_prov.scan_sessions()?;
let cwd_str = cwd.to_string_lossy().to_string();
let session_meta = if let Some(id) = ¶ms.session_id {
sessions
.into_iter()
.find(|s| s.session_id == *id)
.with_context(|| format!("Session not found: {}", id))?
} else {
let mut candidates: Vec<_> = sessions
.into_iter()
.filter(|s| {
s.project_dir
.as_ref()
.map(|d| d == &cwd_str)
.unwrap_or(false)
})
.collect();
candidates.sort_by_key(|s| std::cmp::Reverse(s.last_active_at));
candidates.into_iter().next().with_context(|| {
format!(
"No {} session found in current workspace: {}\nUse --session-id to specify one, or run from the project directory.",
source_prov.name(),
cwd_str
)
})?
};
let source_session_id = session_meta.session_id.clone();
let imported =
load_canonical_session_from_meta(source_prov.as_ref(), ¶ms.from, session_meta)?;
let target_prov = providers::find_provider(¶ms.to)
.with_context(|| format!("Unknown target provider: {}", params.to))?;
let target_capabilities = target_prov.capabilities();
if !target_capabilities.export {
anyhow::bail!(
"Target provider does not support writing sessions: {}",
params.to
);
}
let exported = target_prov.export_session(&imported.session, &target_dir)?;
Ok(SwitchResult {
from_name: source_prov.name().to_string(),
to_name: target_prov.name().to_string(),
source_session_id,
target_session_id: exported.session_id,
resume_command: exported.resume_command,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FindParams {
pub dir: Option<String>,
pub session: Option<String>,
pub providers: Vec<String>,
}
pub fn find_sessions(params: &FindParams) -> Result<Vec<SessionGroup>> {
let provider_ids = resolve_providers(¶ms.providers);
let session_states = session_state::load_state_store().unwrap_or_default();
let mut groups = Vec::new();
for pid in &provider_ids {
let prov = match providers::find_provider(pid) {
Some(p) => p,
None => continue,
};
let capabilities = prov.capabilities();
if !capabilities.scan {
continue;
}
let sessions = prov.scan_sessions()?;
let filtered: Vec<SessionItem> = sessions
.iter()
.map(|s| {
let mut item = SessionItem::from((s, pid.as_str()));
let state = resolve_session_state(
pid,
&s.session_id,
s.title.clone(),
s.project_dir.as_deref(),
&session_states,
);
apply_session_item_state(&mut item, &state);
item
})
.filter(|s| {
let dir_match = params.dir.as_ref().map_or(true, |d| {
s.project_dir
.as_ref()
.map(|pd| pd.contains(d.as_str()))
.unwrap_or(false)
});
let session_match = params.session.as_ref().map_or(true, |pat| {
s.session_id.contains(pat.as_str())
|| s.title
.as_ref()
.map(|t| t.contains(pat.as_str()))
.unwrap_or(false)
|| s.native_title
.as_ref()
.map(|t| t.contains(pat.as_str()))
.unwrap_or(false)
});
dir_match && session_match
})
.collect();
if !filtered.is_empty() {
groups.push(SessionGroup {
provider_id: pid.clone(),
provider_name: prov.name().to_string(),
sessions: filtered,
});
}
}
Ok(groups)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canonical::{
CanonicalSchema, EventBlock, EventLinks, EventMetadata, EventRole, EventSource,
MappingDirection, MappingDisposition, MappingReport, ProviderSessionRef, SessionContext,
SessionEvent, SessionEventKind, SessionIdentity, SessionProvenance,
};
use crate::storage::session_state::SessionStateStore;
use chrono::Utc;
use std::collections::BTreeMap;
#[test]
fn session_item_overlay_prefers_memorph_display_title() {
let meta = ProviderSessionSummary {
session_id: "session-1".to_string(),
title: Some("Native".to_string()),
project_dir: Some("/tmp/project".to_string()),
last_active_at: Some(42),
source_path: Some("/tmp/session.jsonl".to_string()),
};
let mut item = SessionItem::from((&meta, "codex"));
let mut session_states = SessionStateStore::default();
session_state::set_display_title_in_store(
&mut session_states,
"codex",
"session-1",
"Display",
);
let state = resolve_session_state(
"codex",
"session-1",
meta.title.clone(),
meta.project_dir.as_deref(),
&session_states,
);
apply_session_item_state(&mut item, &state);
assert_eq!(item.native_title.as_deref(), Some("Native"));
assert_eq!(item.display_title.as_deref(), Some("Display"));
assert_eq!(item.title.as_deref(), Some("Display"));
}
#[test]
fn session_detail_view_prefers_local_display_title_and_counts_non_lifecycle_messages() {
let imported = ImportedSession {
session: crate::canonical::CanonicalSession {
schema: CanonicalSchema::default(),
identity: SessionIdentity {
canonical_id: "canonical-1".to_string(),
source_title: Some("Native".to_string()),
},
provenance: SessionProvenance {
imported_at: Utc::now(),
imported_by: Some("test".to_string()),
primary_source: ProviderSessionRef {
provider_id: "codex".to_string(),
session_id: "session-1".to_string(),
source_path: Some("/tmp/session.jsonl".to_string()),
},
aliases: Vec::new(),
},
context: SessionContext {
workspace_dir: Some("/tmp/project".to_string()),
created_at: Some(Utc::now()),
last_active_at: Some(Utc::now()),
tags: Vec::new(),
},
events: vec![
SessionEvent {
id: "e1".to_string(),
kind: SessionEventKind::Lifecycle,
role: EventRole::System,
timestamp: Utc::now(),
links: EventLinks::default(),
blocks: vec![EventBlock::Text {
text: "started".to_string(),
}],
metadata: EventMetadata {
source: EventSource {
provider_id: "codex".to_string(),
original_id: None,
original_role: None,
phase: None,
},
model: None,
usage: None,
fidelity: MappingDisposition::Preserved,
provider_ext: BTreeMap::new(),
},
},
SessionEvent {
id: "e2".to_string(),
kind: SessionEventKind::Message,
role: EventRole::Assistant,
timestamp: Utc::now(),
links: EventLinks::default(),
blocks: vec![EventBlock::Text {
text: "hello".to_string(),
}],
metadata: EventMetadata {
source: EventSource {
provider_id: "codex".to_string(),
original_id: None,
original_role: None,
phase: None,
},
model: None,
usage: None,
fidelity: MappingDisposition::Preserved,
provider_ext: BTreeMap::new(),
},
},
],
artifacts: Vec::new(),
extensions: BTreeMap::new(),
},
report: MappingReport::new("codex", MappingDirection::Import),
};
let view = build_session_detail_view(
"codex",
"codex",
"session-1",
Some("/tmp/session.jsonl".to_string()),
Some("codex resume session-1".to_string()),
Some("Native".to_string()),
session_state::ResolvedLocalSessionState {
display_title: Some("Display".to_string()),
archived: false,
hidden: false,
pinned: false,
notes: None,
tags: Vec::new(),
preferred_targets: Vec::new(),
},
imported,
);
assert_eq!(view.title.as_deref(), Some("Display"));
assert_eq!(view.native_title.as_deref(), Some("Native"));
assert_eq!(view.display_title.as_deref(), Some("Display"));
assert_eq!(view.event_count, 2);
assert_eq!(view.message_count, 1);
assert_eq!(view.source_path.as_deref(), Some("/tmp/session.jsonl"));
}
}