use crate::{
config::{HookFailurePolicy, InjectedContentSettings, InjectedContentStyle},
hooks::{HookDiagnostic, HookFailureCategory, HookPhase},
output::{
ActivityEvent, ActivityId, ActivityKind, ActivityMetadata, ActivityStatus, OutputEvent,
ProviderContextInjectionDisplay, redact_sensitive_text,
},
providers::ToolCall,
sessions::{SessionEvent, SessionEventKind},
tools::ToolResult,
tui::{activity::ReasoningSummaryGroup, state},
};
pub(crate) fn hydrate_session_history(
ui_state: &mut state::MissionControlState,
events: &[SessionEvent],
injected_content_settings: &InjectedContentSettings,
) {
let provider_context_content =
provider_context_content_pairs(events, injected_content_settings);
let mut tool_calls = std::collections::HashMap::<String, ToolCall>::new();
let mut tool_activity_ids = std::collections::HashMap::<String, ActivityId>::new();
let mut hook_activity_sequence = 0usize;
let mut started_hooks = std::collections::HashSet::<ActivityId>::new();
let mut pending_assistant_text = String::new();
let mut flushed_assistant_text = String::new();
let mut reasoning_group = ReasoningSummaryGroup::default();
let mut reasoning_turn_sequence = 0usize;
for (event_index, event) in events.iter().enumerate() {
match event.kind() {
Some(SessionEventKind::UserInput) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut flushed_assistant_text,
);
flushed_assistant_text.clear();
reasoning_group.reset();
reasoning_turn_sequence = reasoning_turn_sequence.saturating_add(1);
if let Some(text) = event
.payload
.get("text")
.and_then(serde_json::Value::as_str)
{
ui_state.apply_output_event(&OutputEvent::UserPrompt {
text: text.to_string(),
});
}
}
Some(SessionEventKind::AssistantChunk) => {
if let Some(text) = event
.payload
.get("text")
.and_then(serde_json::Value::as_str)
{
pending_assistant_text.push_str(text);
}
}
Some(SessionEventKind::AssistantOutput) => {
let text = event
.payload
.get("text")
.and_then(serde_json::Value::as_str)
.map(|full_text| {
full_text
.strip_prefix(&flushed_assistant_text)
.unwrap_or(full_text)
.to_string()
})
.unwrap_or_else(|| pending_assistant_text.clone());
pending_assistant_text.clear();
if !text.is_empty() {
flushed_assistant_text.push_str(&text);
ui_state.apply_output_event(&OutputEvent::AssistantComplete { text });
}
}
Some(SessionEventKind::ReasoningSummary) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut flushed_assistant_text,
);
if let Some(text) = event
.payload
.get("text")
.and_then(serde_json::Value::as_str)
{
let persisted_item_id = event
.payload
.get("item_id")
.and_then(serde_json::Value::as_str);
if let Some(item_id) = persisted_item_id {
let turn_id = event
.payload
.get("turn_id")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| reasoning_turn_sequence.to_string());
let activity_id =
ActivityId::new(format!("history/reasoning/{turn_id}/{item_id}"));
if let Some(activity_event) =
reasoning_group.append_identity_event(activity_id, None, text)
{
ui_state.apply_activity_event(activity_event);
}
ui_state.apply_output_event(
&OutputEvent::ThinkingSummaryCompleteIdentified {
text: text.to_string(),
item_id: Some(item_id.to_string()),
turn_id: Some(turn_id),
},
);
} else {
let item_id = format!("legacy-{reasoning_turn_sequence}-{event_index}");
let turn_id = reasoning_turn_sequence.to_string();
if let Some(activity_event) = reasoning_group.append_identity_event(
ActivityId::new(format!("history/reasoning/{turn_id}/{item_id}")),
None,
text,
) {
ui_state.apply_activity_event(activity_event);
}
ui_state.apply_output_event(
&OutputEvent::ThinkingSummaryCompleteIdentified {
text: text.to_string(),
item_id: Some(item_id),
turn_id: Some(turn_id),
},
);
}
}
}
Some(SessionEventKind::ToolCall) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut flushed_assistant_text,
);
let Some(call) = session_event_tool_call(event) else {
continue;
};
let activity_id = ActivityId::new(history_tool_activity_id(&call));
if !call.id.trim().is_empty() {
tool_activity_ids.insert(call.id.clone(), activity_id.clone());
tool_calls.insert(call.id.clone(), call.clone());
}
ui_state.apply_activity_event(ActivityEvent::Started {
id: activity_id.clone(),
parent_id: None,
kind: ActivityKind::Tool,
status: crate::output::pending_activity_status(&call),
metadata: ActivityMetadata::new(crate::output::tool_display_label(&call)),
});
let mut display_call = call.clone();
display_call.id = activity_id.as_str().to_string();
ui_state.apply_output_event(&OutputEvent::ToolStarted {
label: crate::output::tool_display_label(&call),
call: Box::new(display_call),
});
}
Some(SessionEventKind::ToolResult | SessionEventKind::ToolDisplayResult) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut flushed_assistant_text,
);
match session_event_tool_result(event, &tool_calls) {
Ok((call, result)) => {
tool_calls.remove(&call.id);
let activity_id = tool_activity_ids
.get(&call.id)
.cloned()
.unwrap_or_else(|| ActivityId::new(history_tool_activity_id(&call)));
let mut display_call = call.clone();
display_call.id = activity_id.as_str().to_string();
ui_state.apply_output_event(&OutputEvent::ToolResult {
summary: Box::new(crate::output::tool_display_summary(&call, &result)),
call: Box::new(display_call),
result: Box::new(result),
});
}
Err(message) => {
ui_state.apply_output_event(&OutputEvent::Diagnostic {
level: "warning".to_string(),
message,
});
}
}
}
Some(SessionEventKind::TurnStatus) => {
let status = match event
.payload
.get("status")
.and_then(serde_json::Value::as_str)
{
Some("cancelled") => Some(ActivityStatus::Canceled),
Some("failed") => Some(ActivityStatus::Failed),
_ => None,
};
if let Some(status) = status {
for call_id in tool_calls.keys() {
if let Some(id) = tool_activity_ids.get(call_id) {
ui_state.apply_activity_event(ActivityEvent::Finished {
id: id.clone(),
status,
metadata: None,
});
}
}
tool_calls.clear();
}
}
Some(SessionEventKind::HookLifecycle) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut flushed_assistant_text,
);
hydrate_hook_lifecycle_event(
ui_state,
event,
&tool_activity_ids,
&mut started_hooks,
&mut hook_activity_sequence,
);
}
Some(SessionEventKind::HookContextInjection) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut flushed_assistant_text,
);
hydrate_hook_context_injection_event(
ui_state,
event,
injected_content_settings,
&provider_context_content,
&tool_activity_ids,
);
}
Some(SessionEventKind::Diagnostic) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut flushed_assistant_text,
);
hydrate_diagnostic_event(ui_state, event);
}
Some(SessionEventKind::HookDiagnostic) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut flushed_assistant_text,
);
hydrate_hook_diagnostic_event(ui_state, event);
}
Some(SessionEventKind::SubdirInstructionLoad) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut flushed_assistant_text,
);
hydrate_subdir_instruction_load_event(ui_state, event, &tool_activity_ids);
}
_ => {}
}
}
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut flushed_assistant_text,
);
ui_state.finish_assistant_streaming();
}
fn hydrate_diagnostic_event(ui_state: &mut state::MissionControlState, event: &SessionEvent) {
let Some(message) = sanitized_payload_str(&event.payload, "message") else {
return;
};
let level =
sanitized_payload_str(&event.payload, "level").unwrap_or_else(|| "warning".to_string());
ui_state.apply_output_event(&OutputEvent::Diagnostic { level, message });
}
fn hydrate_hook_diagnostic_event(ui_state: &mut state::MissionControlState, event: &SessionEvent) {
let payload = &event.payload;
let Some(message) = sanitized_payload_str(payload, "message") else {
return;
};
let diagnostic = HookDiagnostic {
phase: parse_hook_phase(
sanitized_payload_str(payload, "phase")
.as_deref()
.unwrap_or("after_tool"),
),
tool_name: sanitized_payload_str(payload, "tool")
.or_else(|| sanitized_payload_str(payload, "target_tool"))
.unwrap_or_else(|| "unknown".to_string()),
label: sanitized_payload_str(payload, "label").unwrap_or_else(|| "hook".to_string()),
category: match sanitized_payload_str(payload, "category").as_deref() {
Some("exit") => HookFailureCategory::Exit,
Some("timeout") => HookFailureCategory::Timeout,
Some("stdin") => HookFailureCategory::Stdin,
Some("output-limit") => HookFailureCategory::OutputLimit,
_ => HookFailureCategory::Runner,
},
policy: match sanitized_payload_str(payload, "policy").as_deref() {
Some("ignore") => HookFailurePolicy::Ignore,
Some("block") => HookFailurePolicy::Block,
Some("fail") => HookFailurePolicy::Fail,
_ => HookFailurePolicy::Warn,
},
target_ran: payload
.get("target_ran")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
message,
};
ui_state.apply_output_event(&OutputEvent::HookDiagnostic {
diagnostic: Box::new(diagnostic),
});
}
fn provider_context_content_pairs(
events: &[SessionEvent],
settings: &InjectedContentSettings,
) -> std::collections::HashMap<String, String> {
if settings.style != InjectedContentStyle::Content {
return std::collections::HashMap::new();
}
let injections = events
.iter()
.filter(|event| event.kind() == Some(SessionEventKind::HookContextInjection))
.filter_map(HydratedContextInjection::from_event)
.collect::<Vec<_>>();
let provider_items = events
.iter()
.filter(|event| event.kind() == Some(SessionEventKind::ProviderContextItem))
.filter(|event| {
event
.payload
.get("source")
.and_then(serde_json::Value::as_str)
!= Some("subdir_agents")
})
.filter_map(provider_context_item_content)
.collect::<Vec<_>>();
let successful = injections
.iter()
.filter(|injection| injection.status == "success" && injection.item_count > 0)
.collect::<Vec<_>>();
let total_items = successful
.iter()
.map(|injection| injection.item_count)
.sum::<usize>();
if successful.is_empty() || total_items != provider_items.len() {
return std::collections::HashMap::new();
}
let can_pair_by_order = successful
.iter()
.all(|injection| injection.injection_id.is_some())
|| successful.len() == 1;
if !can_pair_by_order {
return std::collections::HashMap::new();
}
let mut pairs = std::collections::HashMap::new();
let mut cursor = 0usize;
for injection in successful {
let end = cursor.saturating_add(injection.item_count);
let content = provider_items[cursor..end].join("\n");
if !content.is_empty() {
pairs.insert(injection.content_key(), bounded_injected_content(&content));
}
cursor = end;
}
pairs
}
#[derive(Debug, Clone)]
struct HydratedContextInjection {
phase: HookPhase,
label: String,
target: String,
status: String,
item_count: usize,
byte_count: usize,
hook_index: usize,
tool_call_id: Option<String>,
injection_id: Option<String>,
}
impl HydratedContextInjection {
fn from_event(event: &SessionEvent) -> Option<Self> {
let payload = &event.payload;
Some(Self {
phase: parse_hook_phase(payload.get("phase")?.as_str()?),
label: sanitized_payload_str(payload, "label").unwrap_or_else(|| "hook".to_string()),
target: sanitized_payload_str(payload, "target_tool")
.or_else(|| sanitized_payload_str(payload, "target"))
.unwrap_or_else(|| "unknown".to_string()),
status: sanitized_payload_str(payload, "status")
.unwrap_or_else(|| "unknown".to_string()),
item_count: payload
.get("item_count")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as usize,
byte_count: payload
.get("byte_count")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as usize,
hook_index: payload
.get("hook_index")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as usize,
tool_call_id: sanitized_payload_str(payload, "tool_call_id"),
injection_id: sanitized_payload_str(payload, "injection_id"),
})
}
fn content_key(&self) -> String {
self.injection_id.clone().unwrap_or_else(|| {
format!(
"{}:{}:{}",
self.phase.as_str(),
self.target,
self.hook_index
)
})
}
}
fn parse_hook_phase(value: &str) -> HookPhase {
match value {
"before_tool" => HookPhase::Before,
"after_assistant" => HookPhase::AfterAssistant,
"after_reasoning" => HookPhase::AfterReasoning,
_ => HookPhase::After,
}
}
fn provider_context_item_content(event: &SessionEvent) -> Option<String> {
let role = event.payload.get("role")?.as_str()?;
if role != "user" {
return None;
}
event
.payload
.get("content")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
}
fn hydrate_hook_context_injection_event(
ui_state: &mut state::MissionControlState,
event: &SessionEvent,
settings: &InjectedContentSettings,
content_pairs: &std::collections::HashMap<String, String>,
tool_activity_ids: &std::collections::HashMap<String, ActivityId>,
) {
if !settings.show_in_transcript && !settings.show_in_activity_tree {
return;
}
let Some(injection) = HydratedContextInjection::from_event(event) else {
return;
};
let content =
if settings.style == InjectedContentStyle::Content && injection.status == "success" {
injection
.injection_id
.as_ref()
.and_then(|id| content_pairs.get(id).cloned())
.or_else(|| content_pairs.get(&injection.content_key()).cloned())
} else {
None
};
let display = ProviderContextInjectionDisplay::new(
injection.phase,
injection.label.clone(),
injection.target.clone(),
injection.status.clone(),
injection.item_count,
injection.byte_count,
content,
);
if settings.show_in_transcript {
ui_state.apply_output_event(&OutputEvent::ProviderContextInjection {
metadata: display.clone(),
});
}
if settings.show_in_activity_tree {
hydrate_provider_context_injection_activity(
ui_state,
&injection,
&display,
tool_activity_ids,
);
}
}
fn hydrate_provider_context_injection_activity(
ui_state: &mut state::MissionControlState,
injection: &HydratedContextInjection,
display: &ProviderContextInjectionDisplay,
tool_activity_ids: &std::collections::HashMap<String, ActivityId>,
) {
let parent_id = injection
.tool_call_id
.as_deref()
.and_then(|call_id| tool_activity_ids.get(call_id).cloned());
let id = provider_context_injection_activity_id(parent_id.as_ref(), injection);
let metadata = provider_context_injection_activity_metadata(display);
let status = if injection.status == "success" {
ActivityStatus::Success
} else {
ActivityStatus::Failed
};
ui_state.apply_activity_event(ActivityEvent::Started {
id: id.clone(),
parent_id,
kind: ActivityKind::ProviderContextInjection,
status: ActivityStatus::Running,
metadata: metadata.clone(),
});
ui_state.apply_activity_event(ActivityEvent::Finished {
id,
status,
metadata: Some(metadata),
});
}
fn provider_context_injection_activity_id(
parent_activity_id: Option<&ActivityId>,
injection: &HydratedContextInjection,
) -> ActivityId {
let phase = injection.phase.as_str();
if let Some(parent_activity_id) = parent_activity_id {
ActivityId::new(format!(
"{}/{phase}-provider-context-injection-{}",
parent_activity_id.as_str(),
injection.content_key()
))
} else {
ActivityId::new(format!(
"provider-context-injection/{}/{}/{}",
injection.target,
phase,
injection.content_key()
))
}
}
fn provider_context_injection_activity_metadata(
display: &ProviderContextInjectionDisplay,
) -> ActivityMetadata {
ActivityMetadata {
label: format!("provider context injection {}", display.label),
detail: Some(format!(
"provider context injection '{}' for {} {}: {} {} {} bytes",
display.label,
display.target,
display.phase.as_str(),
display.status,
item_count_text(display.item_count),
display.byte_count
)),
fields: vec![
("phase".to_string(), display.phase.as_str().to_string()),
("hook_label".to_string(), display.label.clone()),
("target".to_string(), display.target.clone()),
("status".to_string(), display.status.clone()),
("item_count".to_string(), display.item_count.to_string()),
("bytes".to_string(), display.byte_count.to_string()),
],
}
}
fn item_count_text(count: usize) -> String {
if count == 1 {
"1 item".to_string()
} else {
format!("{count} items")
}
}
fn bounded_injected_content(text: &str) -> String {
const MAX_INJECTED_CONTENT_CHARS: usize = 2000;
let redacted = redact_sensitive_text(text);
let mut chars = redacted.chars();
let truncated = chars
.by_ref()
.take(MAX_INJECTED_CONTENT_CHARS)
.collect::<String>();
if chars.next().is_some() {
format!("{truncated}…")
} else {
truncated
}
}
fn flush_pending_assistant_history(
ui_state: &mut state::MissionControlState,
pending_assistant_text: &mut String,
flushed_assistant_text: &mut String,
) {
if !pending_assistant_text.is_empty() {
let text = std::mem::take(pending_assistant_text);
flushed_assistant_text.push_str(&text);
ui_state.apply_output_event(&OutputEvent::AssistantComplete { text });
}
}
fn session_event_tool_call(event: &SessionEvent) -> Option<ToolCall> {
Some(ToolCall {
id: event.payload.get("id")?.as_str()?.to_string(),
name: event.payload.get("name")?.as_str()?.to_string(),
arguments: event
.payload
.get("arguments")
.cloned()
.unwrap_or_else(|| serde_json::json!({})),
})
}
fn session_event_tool_result(
event: &SessionEvent,
tool_calls: &std::collections::HashMap<String, ToolCall>,
) -> Result<(ToolCall, ToolResult), String> {
let result_value = event.payload.get("result").ok_or_else(|| {
"session replay warning: tool_result event missing result payload".to_string()
})?;
let result: ToolResult = serde_json::from_value(result_value.clone()).map_err(|_| {
"session replay warning: tool_result event has malformed result payload".to_string()
})?;
let call_id = event
.payload
.get("call_id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let call = tool_calls
.get(call_id)
.cloned()
.unwrap_or_else(|| ToolCall {
id: call_id.to_string(),
name: result.tool_name.clone(),
arguments: serde_json::json!({}),
});
Ok((call, result))
}
fn hydrate_subdir_instruction_load_event(
ui_state: &mut state::MissionControlState,
event: &SessionEvent,
tool_activity_ids: &std::collections::HashMap<String, ActivityId>,
) {
let payload = &event.payload;
if payload.get("source").and_then(serde_json::Value::as_str) != Some("subdir_agents") {
return;
}
if payload.get("status").and_then(serde_json::Value::as_str) != Some("success") {
return;
}
let Some(path) = sanitized_payload_str(payload, "path") else {
return;
};
let bytes = payload
.get("bytes")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as usize;
let parent_activity_id = payload
.get("tool_call_id")
.and_then(serde_json::Value::as_str)
.and_then(|call_id| tool_activity_ids.get(call_id))
.map(|id| id.as_str().to_string());
ui_state.apply_output_event(&OutputEvent::SubdirInstructionInjection {
path: std::path::PathBuf::from(&path),
bytes,
parent_activity_id: parent_activity_id.clone(),
});
let id = ActivityId::new(format!(
"history/subdir-instruction-load/{}",
event.timestamp.timestamp_micros()
));
let metadata = ActivityMetadata {
label: format!("Loaded subdirectory instructions: {path}"),
detail: Some(format!(
"Loaded subdirectory instructions: {path} ({bytes} bytes)"
)),
fields: vec![
("source".to_string(), "subdir_agents".to_string()),
("path".to_string(), path),
("status".to_string(), "success".to_string()),
("bytes".to_string(), bytes.to_string()),
],
};
ui_state.apply_activity_event(ActivityEvent::Started {
id: id.clone(),
parent_id: parent_activity_id.map(ActivityId::new),
kind: ActivityKind::ProviderContextInjection,
status: ActivityStatus::Running,
metadata: metadata.clone(),
});
ui_state.apply_activity_event(ActivityEvent::Finished {
id,
status: ActivityStatus::Success,
metadata: Some(metadata),
});
}
fn hydrate_hook_lifecycle_event(
ui_state: &mut state::MissionControlState,
event: &SessionEvent,
tool_activity_ids: &std::collections::HashMap<String, ActivityId>,
started_hooks: &mut std::collections::HashSet<ActivityId>,
hook_activity_sequence: &mut usize,
) {
let payload = &event.payload;
let status = payload
.get("status")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let id = payload
.get("activity_id")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ActivityId::new)
.unwrap_or_else(|| {
*hook_activity_sequence = hook_activity_sequence.saturating_add(1);
ActivityId::new(format!("history/hook-lifecycle-{hook_activity_sequence}"))
});
let parent_id = payload
.get("tool_call_id")
.and_then(serde_json::Value::as_str)
.and_then(|call_id| tool_activity_ids.get(call_id).cloned());
let metadata = hook_lifecycle_metadata(payload);
match status {
"started" => {
started_hooks.insert(id.clone());
ui_state.apply_activity_event(ActivityEvent::Started {
id,
parent_id,
kind: ActivityKind::Hook,
status: ActivityStatus::Running,
metadata,
});
}
"success" | "failed" | "blocked" | "ignored" => {
if !started_hooks.contains(&id) {
ui_state.apply_activity_event(ActivityEvent::Started {
id: id.clone(),
parent_id,
kind: ActivityKind::Hook,
status: ActivityStatus::Running,
metadata: metadata.clone(),
});
started_hooks.insert(id.clone());
}
ui_state.apply_activity_event(ActivityEvent::Finished {
id,
status: if status == "success" {
ActivityStatus::Success
} else {
ActivityStatus::Failed
},
metadata: Some(metadata),
});
}
_ => {}
}
}
fn hook_lifecycle_metadata(payload: &serde_json::Value) -> ActivityMetadata {
let phase = sanitized_payload_str(payload, "phase").unwrap_or_else(|| "hook".to_string());
let label = sanitized_payload_str(payload, "label").unwrap_or_else(|| "hook".to_string());
let target_tool =
sanitized_payload_str(payload, "target_tool").unwrap_or_else(|| "unknown".to_string());
let status = sanitized_payload_str(payload, "status").unwrap_or_else(|| "unknown".to_string());
let policy = sanitized_payload_str(payload, "policy").unwrap_or_else(|| "warn".to_string());
let category = sanitized_payload_str(payload, "category");
let mut fields = vec![
("phase".to_string(), phase.clone()),
("hook_label".to_string(), label.clone()),
("target_tool".to_string(), target_tool.clone()),
("status".to_string(), status.clone()),
("policy".to_string(), policy.clone()),
];
if let Some(category) = &category {
fields.push(("category".to_string(), category.clone()));
}
if let Some(target_ran) = payload
.get("target_ran")
.and_then(serde_json::Value::as_bool)
{
fields.push(("target_ran".to_string(), target_ran.to_string()));
}
if let Some(elapsed_ms) = payload
.get("elapsed_ms")
.and_then(serde_json::Value::as_u64)
{
fields.push(("elapsed_ms".to_string(), elapsed_ms.to_string()));
}
let mut detail = format!("recorded {phase} hook '{label}' for tool '{target_tool}' {status}");
if let Some(category) = category {
detail.push_str(&format!(" category={category} policy={policy}"));
}
if let Some(message) = sanitized_payload_str(payload, "message") {
detail.push_str(&format!(": {message}"));
}
ActivityMetadata {
label: format!("{phase} hook {label} on {target_tool}"),
detail: Some(redact_sensitive_text(&detail)),
fields,
}
}
fn sanitized_payload_str(payload: &serde_json::Value, key: &str) -> Option<String> {
payload
.get(key)
.and_then(serde_json::Value::as_str)
.map(redact_sensitive_text)
}
fn history_tool_activity_id(call: &ToolCall) -> String {
if call.id.trim().is_empty() {
format!("history/tool-{}", call.name)
} else {
call.id.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hydrate_session_history_preserves_assistant_turn_with_repeated_prefix() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "prefix-session";
let events = vec![
crate::sessions::SessionEvent::new(
"user_input",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"first prompt"}),
),
crate::sessions::SessionEvent::new(
"assistant_output",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"shared prefix"}),
),
crate::sessions::SessionEvent::new(
"user_input",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"second prompt"}),
),
crate::sessions::SessionEvent::new(
"assistant_output",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"shared prefix and new content"}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
assert_eq!(
ui_state.transcript,
vec![
"you: first prompt".to_string(),
"assistant: shared prefix".to_string(),
"you: second prompt".to_string(),
"assistant: shared prefix and new content".to_string(),
]
);
}
#[test]
fn hydrate_session_history_replays_reasoning_summary_in_order() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "reasoning-session";
let events = vec![
crate::sessions::SessionEvent::new(
"user_input",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"first prompt"}),
),
crate::sessions::SessionEvent::new(
"reasoning_summary",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"provider summary"}),
),
crate::sessions::SessionEvent::new(
"reasoning_summary",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"planned tool"}),
),
crate::sessions::SessionEvent::new(
"assistant_output",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"answer"}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
assert_eq!(
ui_state.transcript,
vec![
"you: first prompt".to_string(),
"thinking: provider summary".to_string(),
"thinking: planned tool".to_string(),
"assistant: answer".to_string(),
]
);
assert_eq!(
ui_state.roots,
vec![
ActivityId::new("history/reasoning/1/legacy-1-1"),
ActivityId::new("history/reasoning/1/legacy-1-2")
]
);
}
#[test]
fn hydrate_reasoning_identity_uses_persisted_turn_namespace() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "reasoning-turn-id-session";
let make_event = |text: &str, turn_id: &str| {
crate::sessions::SessionEvent::new(
"reasoning_summary",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({
"text": text,
"item_id": "rs_same",
"turn_id": turn_id
}),
)
};
let events = vec![
make_event("first", "persisted-a"),
make_event("updated", "persisted-a"),
make_event("second", "persisted-b"),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
assert_eq!(
ui_state.roots,
vec![
ActivityId::new("history/reasoning/persisted-a/rs_same"),
ActivityId::new("history/reasoning/persisted-b/rs_same"),
]
);
assert_eq!(
ui_state.transcript,
vec![
"thinking: updated".to_string(),
"thinking: second".to_string()
]
);
}
#[test]
fn hydrate_session_history_resets_reasoning_group_per_user_turn() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "reasoning-session";
let events = vec![
crate::sessions::SessionEvent::new(
"user_input",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"first prompt"}),
),
crate::sessions::SessionEvent::new(
"reasoning_summary",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"first summary"}),
),
crate::sessions::SessionEvent::new(
"user_input",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"second prompt"}),
),
crate::sessions::SessionEvent::new(
"reasoning_summary",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"second summary"}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
assert_eq!(
ui_state.roots,
vec![
ActivityId::new("history/reasoning/1/legacy-1-1"),
ActivityId::new("history/reasoning/2/legacy-2-3")
]
);
assert_eq!(
ui_state.nodes[&ActivityId::new("history/reasoning/1/legacy-1-1")]
.metadata
.detail
.as_deref(),
Some("1. first summary")
);
assert_eq!(
ui_state.nodes[&ActivityId::new("history/reasoning/2/legacy-2-3")]
.metadata
.detail
.as_deref(),
Some("1. second summary")
);
}
#[test]
fn regression_preserves_hydrated_tool_detail_and_transcript_row() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "tool-session";
let events = vec![
crate::sessions::SessionEvent::new(
"tool_call",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"id":"call_bash","name":"bash","arguments":{"command":"false","timeout":5}}),
),
crate::sessions::SessionEvent::new(
"tool_result",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"call_id":"call_bash","result":{"tool_name":"bash","success":false,"content":"stderr: boom","metadata":{"exit_code":1}}}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
let detail = ui_state.selected_activity_text();
assert!(!detail.contains("TOOL START"));
assert!(detail.contains("Status: FAILED"));
assert!(detail.contains("Metadata/Params:"));
assert!(detail.contains("\"command\": \"false\""));
assert!(detail.contains("Sanitized Output:"));
assert!(detail.contains("stderr: boom"));
assert!(!detail.contains("TOOL END"));
assert!(
ui_state
.transcript
.iter()
.any(|line| line.starts_with("tool:") && line.contains("bash"))
);
}
#[test]
fn round_four_hydrates_local_before_hook_failure_detail() {
let temp = tempfile::TempDir::new().unwrap();
let events = vec![
crate::sessions::SessionEvent::new(
"tool_call",
"hook-failure".to_string(),
temp.path().to_path_buf(),
serde_json::json!({"id":"call_hook","name":"bash","arguments":{"command":"false"}}),
),
crate::sessions::SessionEvent::new(
"tool_display_result",
"hook-failure".to_string(),
temp.path().to_path_buf(),
serde_json::json!({"call_id":"call_hook","result":{"tool_name":"bash","success":false,"content":"tool call failed by local before_tool hook policy: tool=bash hook=<redacted> category=exit","metadata":{"call_id":"call_hook","hook_failed":true}}}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
let detail = ui_state.selected_activity_text();
assert!(detail.contains("Status: FAILED"), "{detail}");
assert!(detail.contains("\"hook_failed\": true"), "{detail}");
assert!(detail.contains("before_tool hook policy"), "{detail}");
}
#[test]
fn hydrate_interrupted_tool_uses_terminal_status_and_keeps_params() {
let temp = tempfile::TempDir::new().unwrap();
let events = vec![
crate::sessions::SessionEvent::new(
"tool_call",
"interrupted".to_string(),
temp.path().to_path_buf(),
serde_json::json!({"id":"call_write","name":"write","arguments":{"path":"a.txt","content":"draft"}}),
),
crate::sessions::SessionEvent::new(
"turn_status",
"interrupted".to_string(),
temp.path().to_path_buf(),
serde_json::json!({"status":"cancelled","assistant_text":""}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
let detail = ui_state.selected_activity_text();
assert!(detail.contains("Status: CANCELED"), "{detail}");
assert!(detail.contains("\"path\": \"a.txt\""), "{detail}");
assert!(detail.contains("\"content\": \"draft\""), "{detail}");
assert!(detail.contains("\"metadata\": {}"), "{detail}");
let (_, output) = detail.split_once("Sanitized Output:").unwrap();
assert!(output.trim().is_empty(), "{detail}");
}
#[test]
fn hydrate_session_history_warns_for_malformed_tool_result() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "malformed-tool-result-session";
let events = vec![crate::sessions::SessionEvent::new(
"tool_result",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"call_id":"call_bash","result":{"tool_name":42,"success":"nope"}}),
)];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
assert_eq!(ui_state.transcript.len(), 1);
assert!(
ui_state.transcript[0].contains(
"warning: session replay warning: tool_result event has malformed result payload"
),
"{:?}",
ui_state.transcript
);
assert!(!ui_state.transcript[0].contains("call_bash"));
assert!(!ui_state.transcript[0].contains("tool_name"));
}
#[test]
fn hydrate_session_history_hook_lifecycle_nests_under_tool() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "hook-session";
let events = vec![
crate::sessions::SessionEvent::new(
"tool_call",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({"id":"call_bash","name":"bash","arguments":{"command":"true"}}),
),
crate::sessions::SessionEvent::new(
"hook_lifecycle",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({
"phase":"before_tool",
"label":"audit",
"target_tool":"bash",
"status":"started",
"policy":"warn",
"target_ran":false,
"tool_call_id":"call_bash",
"activity_id":"call_bash/before_tool-hook-0",
"hook_index":0
}),
),
crate::sessions::SessionEvent::new(
"hook_lifecycle",
session_id.to_string(),
temp.path().to_path_buf(),
serde_json::json!({
"phase":"before_tool",
"label":"audit",
"target_tool":"bash",
"status":"success",
"policy":"warn",
"target_ran":false,
"tool_call_id":"call_bash",
"activity_id":"call_bash/before_tool-hook-0",
"hook_index":0,
"elapsed_ms":1
}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
let hook_id = ActivityId::new("call_bash/before_tool-hook-0");
assert_eq!(
ui_state.nodes.get(&hook_id).unwrap().kind,
ActivityKind::Hook
);
assert_eq!(
ui_state.nodes.get(&hook_id).unwrap().parent_id,
Some(ActivityId::new("call_bash"))
);
assert_eq!(
ui_state.nodes.get(&hook_id).unwrap().status,
ActivityStatus::Success
);
}
#[test]
fn hydrate_session_history_hook_lifecycle_missing_correlation_is_root() {
let temp = tempfile::TempDir::new().unwrap();
let event = crate::sessions::SessionEvent::new(
"hook_lifecycle",
"hook-session".to_string(),
temp.path().to_path_buf(),
serde_json::json!({
"phase":"after_tool",
"label":"orphan",
"target_tool":"bash",
"status":"failed",
"policy":"warn",
"category":"exit",
"message":"failed without correlation"
}),
);
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &[event], &InjectedContentSettings::default());
assert_eq!(ui_state.roots.len(), 1);
let root = ui_state.roots[0].clone();
assert_eq!(ui_state.nodes.get(&root).unwrap().kind, ActivityKind::Hook);
assert_eq!(
ui_state.nodes.get(&root).unwrap().status,
ActivityStatus::Failed
);
}
#[test]
fn hydrate_session_history_hook_lifecycle_detail_is_sanitized() {
let temp = tempfile::TempDir::new().unwrap();
let secret = "sk-hookSecret123456";
let event = crate::sessions::SessionEvent::new(
"hook_lifecycle",
"hook-session".to_string(),
temp.path().to_path_buf(),
serde_json::json!({
"phase":"before_tool",
"label":format!("audit {secret}"),
"target_tool":"bash",
"status":"failed",
"policy":"warn",
"category":"exit",
"message":format!("Authorization: Bearer {secret}"),
"command":format!("echo {secret}"),
"stdout":secret,
"stderr":secret
}),
);
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &[event], &InjectedContentSettings::default());
let detail = ui_state.selected_activity_text();
assert!(detail.contains("<redacted>"));
assert!(!detail.contains(secret));
assert!(!detail.contains("echo"));
assert!(!detail.contains("stdout"));
assert!(!detail.contains("stderr"));
}
#[test]
fn hydrate_session_history_replays_provider_context_injection_metadata_style() {
let temp = tempfile::TempDir::new().unwrap();
let events = vec![provider_context_injection_event(
temp.path(),
"success",
1,
Some("after_tool:call_bash:0"),
None,
)];
let mut ui_state = state::MissionControlState::default();
let settings = InjectedContentSettings {
show_in_transcript: true,
show_in_activity_tree: true,
style: InjectedContentStyle::Metadata,
};
hydrate_session_history(&mut ui_state, &events, &settings);
assert_eq!(
ui_state.transcript,
vec!["provider context injection · inject docs · success · 1 item · 42 bytes"]
);
let id =
ActivityId::new("provider-context-injection/bash/after_tool/after_tool:call_bash:0");
let node = ui_state.nodes.get(&id).unwrap();
assert_eq!(node.kind, ActivityKind::ProviderContextInjection);
assert_eq!(node.status, ActivityStatus::Success);
assert_eq!(
node.metadata.fields,
vec![
("phase".to_string(), "after_tool".to_string()),
("hook_label".to_string(), "inject docs".to_string()),
("target".to_string(), "bash".to_string()),
("status".to_string(), "success".to_string()),
("item_count".to_string(), "1".to_string()),
("bytes".to_string(), "42".to_string()),
]
);
}
#[test]
fn hydrate_session_history_replays_provider_context_injection_content_style() {
let temp = tempfile::TempDir::new().unwrap();
let events = vec![
provider_context_injection_event(
temp.path(),
"success",
1,
Some("after_tool:call_bash:0"),
None,
),
provider_context_item_event(temp.path(), "injected content body"),
];
let mut ui_state = state::MissionControlState::default();
let settings = InjectedContentSettings {
show_in_transcript: true,
show_in_activity_tree: false,
style: InjectedContentStyle::Content,
};
hydrate_session_history(&mut ui_state, &events, &settings);
assert_eq!(
ui_state.transcript,
vec![
"provider context injection · inject docs · success · 1 item · 42 bytes\ninjected content body"
]
);
assert!(ui_state.nodes.is_empty());
}
#[test]
fn hydrate_session_history_provider_context_injection_ambiguous_multi_hook_is_metadata_only() {
let temp = tempfile::TempDir::new().unwrap();
let events = vec![
provider_context_injection_event(temp.path(), "success", 1, None, None),
provider_context_injection_event(temp.path(), "success", 1, None, None),
provider_context_item_event(temp.path(), "first injected content"),
provider_context_item_event(temp.path(), "second injected content"),
];
let mut ui_state = state::MissionControlState::default();
let settings = InjectedContentSettings {
show_in_transcript: true,
show_in_activity_tree: false,
style: InjectedContentStyle::Content,
};
hydrate_session_history(&mut ui_state, &events, &settings);
assert_eq!(ui_state.transcript.len(), 2);
assert!(!ui_state.transcript[0].contains("first injected content"));
assert!(!ui_state.transcript[1].contains("second injected content"));
}
#[test]
fn hydrate_session_history_provider_context_injection_distinguishes_repeated_call_ids() {
let temp = tempfile::TempDir::new().unwrap();
let events = vec![
provider_context_injection_event(
temp.path(),
"success",
1,
Some("after_tool:call_one:0"),
Some("call_one"),
),
provider_context_injection_event(
temp.path(),
"success",
1,
Some("after_tool:call_two:0"),
Some("call_two"),
),
provider_context_item_event(temp.path(), "first injected content"),
provider_context_item_event(temp.path(), "second injected content"),
];
let mut ui_state = state::MissionControlState::default();
let settings = InjectedContentSettings {
show_in_transcript: true,
show_in_activity_tree: false,
style: InjectedContentStyle::Content,
};
hydrate_session_history(&mut ui_state, &events, &settings);
assert!(ui_state.transcript[0].contains("first injected content"));
assert!(ui_state.transcript[1].contains("second injected content"));
}
#[test]
fn hydrate_session_history_provider_context_injection_nests_under_tool_call_id() {
let temp = tempfile::TempDir::new().unwrap();
let events = vec![
crate::sessions::SessionEvent::new(
"tool_call",
"injection-session".to_string(),
temp.path().to_path_buf(),
serde_json::json!({"id":"call_bash","name":"bash","arguments":{"command":"true"}}),
),
provider_context_injection_event(
temp.path(),
"success",
1,
Some("after_tool:call_bash:0"),
Some("call_bash"),
),
];
let mut ui_state = state::MissionControlState::default();
let settings = InjectedContentSettings {
show_in_transcript: false,
show_in_activity_tree: true,
style: InjectedContentStyle::Metadata,
};
hydrate_session_history(&mut ui_state, &events, &settings);
let id = ActivityId::new(
"call_bash/after_tool-provider-context-injection-after_tool:call_bash:0",
);
assert_eq!(
ui_state.nodes.get(&id).unwrap().parent_id,
Some(ActivityId::new("call_bash"))
);
}
#[test]
fn hydrate_session_history_provider_context_injection_disabled_settings_emit_no_rows() {
let temp = tempfile::TempDir::new().unwrap();
let events = vec![
provider_context_injection_event(
temp.path(),
"success",
1,
Some("after_tool:call_bash:0"),
None,
),
provider_context_item_event(temp.path(), "injected content body"),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
assert!(ui_state.transcript.is_empty());
assert!(ui_state.nodes.is_empty());
}
#[test]
fn hydrate_session_history_replays_subdir_instruction_load() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("nested/AGENTS.md");
let events = vec![crate::sessions::SessionEvent::new(
"subdir_instruction_load",
"subdir-session".to_string(),
temp.path().to_path_buf(),
serde_json::json!({
"source":"subdir_agents",
"path":path,
"status":"success",
"bytes":1234
}),
)];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
let expected_label = format!(
"Loaded subdirectory instructions: {}",
temp.path().join("nested/AGENTS.md").display()
);
assert_eq!(ui_state.transcript, vec![expected_label.clone()]);
assert_eq!(ui_state.roots.len(), 1);
let node = ui_state.nodes.get(&ui_state.roots[0]).unwrap();
assert_eq!(node.kind, ActivityKind::ProviderContextInjection);
assert_eq!(node.status, ActivityStatus::Success);
assert_eq!(node.metadata.label, expected_label);
assert_eq!(
node.metadata.fields,
vec![
("source".to_string(), "subdir_agents".to_string()),
(
"path".to_string(),
temp.path().join("nested/AGENTS.md").display().to_string()
),
("status".to_string(), "success".to_string()),
("bytes".to_string(), "1234".to_string()),
]
);
}
fn provider_context_injection_event(
path: &std::path::Path,
status: &str,
item_count: usize,
injection_id: Option<&str>,
tool_call_id: Option<&str>,
) -> crate::sessions::SessionEvent {
let mut payload = serde_json::json!({
"phase":"after_tool",
"label":"inject docs",
"target_tool":"bash",
"status":status,
"item_count":item_count,
"byte_count":42,
"max_bytes":2048,
"hook_index":0
});
if let Some(injection_id) = injection_id {
payload["injection_id"] = serde_json::Value::String(injection_id.to_string());
}
if let Some(tool_call_id) = tool_call_id {
payload["tool_call_id"] = serde_json::Value::String(tool_call_id.to_string());
}
crate::sessions::SessionEvent::new(
"hook_context_injection",
"injection-session".to_string(),
path.to_path_buf(),
payload,
)
}
fn provider_context_item_event(
path: &std::path::Path,
content: &str,
) -> crate::sessions::SessionEvent {
crate::sessions::SessionEvent::new(
"provider_context_item",
"injection-session".to_string(),
path.to_path_buf(),
serde_json::json!({"role":"user","content":content}),
)
}
#[test]
fn hydrate_session_history_replays_diagnostics_with_redaction_and_order() {
let temp = tempfile::TempDir::new().unwrap();
let secret = "sentinelHydratedToken123";
let events = vec![
crate::sessions::SessionEvent::new(
"assistant_chunk",
"diagnostic-session".to_string(),
temp.path().to_path_buf(),
serde_json::json!({"text":"partial"}),
),
crate::sessions::SessionEvent::new(
"diagnostic",
"diagnostic-session".to_string(),
temp.path().to_path_buf(),
serde_json::json!({"level":"warning","message":format!("Bearer {secret}")}),
),
crate::sessions::SessionEvent::new(
"hook_diagnostic",
"diagnostic-session".to_string(),
temp.path().to_path_buf(),
serde_json::json!({
"phase":"after_tool",
"tool":"bash",
"label":"audit",
"category":"exit",
"policy":"warn",
"target_ran":false,
"message":format!("hook Bearer {secret}")
}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
assert_eq!(
ui_state.transcript,
vec![
"assistant: partial".to_string(),
"warning: Bearer <redacted>".to_string(),
"hook: hook Bearer <redacted>".to_string(),
]
);
assert!(!ui_state.transcript.iter().any(|line| line.contains(secret)));
let mut live_state = state::MissionControlState::default();
for event in [
OutputEvent::AssistantDelta {
text: "partial".to_string(),
},
OutputEvent::Diagnostic {
level: "warning".to_string(),
message: format!("Bearer {secret}"),
},
OutputEvent::HookDiagnostic {
diagnostic: Box::new(HookDiagnostic {
phase: HookPhase::After,
tool_name: "bash".to_string(),
label: "audit".to_string(),
category: HookFailureCategory::Exit,
policy: HookFailurePolicy::Warn,
target_ran: false,
message: format!("hook Bearer {secret}"),
}),
},
] {
live_state.apply_output_event(&event);
}
assert_eq!(ui_state.transcript, live_state.transcript);
}
#[test]
fn hydrate_session_history_ignores_malformed_diagnostic_fields() {
let temp = tempfile::TempDir::new().unwrap();
let events = vec![
crate::sessions::SessionEvent::new(
"diagnostic",
"malformed-diagnostic-session".to_string(),
temp.path().to_path_buf(),
serde_json::json!({"message":42}),
),
crate::sessions::SessionEvent::new(
"hook_diagnostic",
"malformed-diagnostic-session".to_string(),
temp.path().to_path_buf(),
serde_json::json!({"message":"ok","target_ran":"yes"}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
assert_eq!(ui_state.transcript, vec!["hook: ok".to_string()]);
}
}