use std::path::Path;
use crate::agent_events::{AgentEvent, ToolCallStatus, ToolMutationStatus};
use crate::orchestration::AgentSessionReplayEvent;
use crate::value::VmError;
use harn_session_store::{ReadRange, SessionEventKind, SessionStore, StoreError, StoredEvent};
const RESTORE_PAGE: usize = 512;
pub async fn load_canonical_session_replay_events(
project_root: &Path,
session_id: &str,
) -> Result<Option<Vec<AgentSessionReplayEvent>>, VmError> {
let Some(store) = crate::stdlib::session_store::open_existing_canonical_store(project_root)?
else {
return Ok(None);
};
load_canonical_session_replay_events_from_store(&store, session_id).await
}
pub async fn load_canonical_session_replay_events_from_store(
store: &dyn SessionStore,
session_id: &str,
) -> Result<Option<Vec<AgentSessionReplayEvent>>, VmError> {
match store.describe(session_id).await {
Ok(_) => {}
Err(StoreError::NotFound(_)) => return Ok(None),
Err(error) => {
return Err(VmError::Runtime(format!(
"canonical session store describe {session_id}: {error}"
)))
}
}
let mut events = Vec::new();
let mut from = None;
loop {
let page = store
.read(
session_id,
ReadRange {
from_event_id: from,
limit: Some(RESTORE_PAGE),
..ReadRange::default()
},
)
.await
.map_err(|error| {
VmError::Runtime(format!(
"canonical session store read {session_id}: {error}"
))
})?;
for stored in page.events {
if let Some(event) = replay_event_from_stored(session_id, &stored) {
events.push(event);
}
}
match page.next_cursor {
Some(cursor) => from = Some(cursor),
None => break,
}
}
Ok(Some(events))
}
fn replay_event_from_stored(
session_id: &str,
stored: &StoredEvent,
) -> Option<AgentSessionReplayEvent> {
let transcript = stored.payload.get("transcript_event")?;
if transcript
.get("visibility")
.and_then(serde_json::Value::as_str)
== Some("internal")
{
return None;
}
let role = transcript
.get("role")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let text = transcript
.get("text")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let event = match (&stored.kind, role) {
(SessionEventKind::Message, "user") => AgentEvent::UserMessage {
session_id: session_id.to_string(),
message_id: transcript
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or(&stored.record_hash)
.to_string(),
content: user_content_blocks(transcript, text),
},
(SessionEventKind::Message, _) if !text.is_empty() => AgentEvent::AgentMessageChunk {
session_id: session_id.to_string(),
content: text.to_string(),
},
(SessionEventKind::ToolCall, _) => AgentEvent::ToolCall {
session_id: session_id.to_string(),
tool_call_id: tool_call_id(stored, transcript)?,
tool_name: tool_name(transcript),
kind: None,
status: ToolCallStatus::Completed,
raw_input: transcript
.get("input")
.cloned()
.unwrap_or(serde_json::Value::Null),
parsing: None,
audit: None,
},
(SessionEventKind::ToolResult, _) => AgentEvent::ToolCallUpdate {
session_id: session_id.to_string(),
tool_call_id: tool_call_id(stored, transcript)?,
tool_name: tool_name(transcript),
status: ToolCallStatus::Completed,
raw_output: Some(serde_json::Value::String(text.to_string())),
error: None,
duration_ms: None,
execution_duration_ms: None,
error_category: None,
mutation_status: ToolMutationStatus::Unknown,
changed_paths: None,
data: None,
executor: None,
parsing: None,
raw_input: None,
raw_input_partial: None,
audit: None,
},
_ => return None,
};
Some(AgentSessionReplayEvent {
event_id: stored.event_id,
kind: stored_kind_label(&stored.kind),
occurred_at_ms: stored.ts_ms,
event,
})
}
fn user_content_blocks(transcript: &serde_json::Value, text: &str) -> Vec<serde_json::Value> {
match transcript
.get("blocks")
.and_then(serde_json::Value::as_array)
{
Some(blocks) if !blocks.is_empty() => blocks.clone(),
_ => vec![serde_json::json!({"type": "text", "text": text})],
}
}
fn tool_call_id(stored: &StoredEvent, transcript: &serde_json::Value) -> Option<String> {
transcript
.get("tool_call_id")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.or_else(|| {
stored
.headers
.get("tool_call_id")
.filter(|value| !value.is_empty())
.cloned()
})
}
fn tool_name(transcript: &serde_json::Value) -> String {
transcript
.get("tool_name")
.or_else(|| transcript.get("name"))
.and_then(serde_json::Value::as_str)
.unwrap_or("tool")
.to_string()
}
fn stored_kind_label(kind: &SessionEventKind) -> String {
match kind {
SessionEventKind::Custom { custom_type } => custom_type.clone(),
other => serde_json::to_value(other)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_else(|| "message".to_string()),
}
}
#[cfg(test)]
mod tests;