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, TurnStatus},
subagents::{
MAX_SUBAGENT_TASKS, SubagentStatus, SubagentTask, SubagentTaskResult, SubagentsArgs,
SubagentsOutput,
},
tools::{ToolResult, contract::tool_name},
tui::{activity::ReasoningSummaryGroup, state},
};
const MAX_SUBAGENT_REPLAY_JSON_BYTES: usize = 4 * 1024 * 1024;
const HYDRATED_SUBAGENT_DEPTH: usize = 1;
pub(crate) fn hydrate_session_history(
ui_state: &mut state::MissionControlState,
events: &[SessionEvent],
injected_content_settings: &InjectedContentSettings,
) {
if let Some(event) = events.first() {
ui_state.session_files.session_id = Some(event.session_id.clone());
ui_state.session_files.cwd = event.cwd.clone();
}
let mut primary_usage_since_output = false;
let mut recorded_primary_outputs = std::collections::HashSet::new();
for (index, event) in events.iter().enumerate() {
if event.kind() == Some(SessionEventKind::Compaction)
&& let Some(value) = event.payload.get("session_usage")
{
match crate::sessions::usage::SessionUsageLedger::from_checkpoint(value.clone()) {
Ok(ledger) => ui_state.session_usage = ledger,
Err(_) => ui_state.session_usage.incomplete = true,
}
ui_state.refresh_session_cache_percent();
primary_usage_since_output = event
.payload
.get("primary_usage_since_output")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
}
if event.kind() == Some(SessionEventKind::Compaction)
&& event.payload.get("aggregate").is_some()
&& event.payload.get("session_usage").is_none()
{
ui_state.session_usage.incomplete = true;
}
if event.kind() == Some(SessionEventKind::AssistantOutput) {
if primary_usage_since_output {
recorded_primary_outputs.insert(index);
}
primary_usage_since_output = false;
}
if event.turn_status_payload().is_some_and(|status| {
matches!(status.status, TurnStatus::Cancelled | TurnStatus::Failed)
}) {
if !primary_usage_since_output {
ui_state.session_usage.incomplete = true;
}
primary_usage_since_output = false;
}
if event.kind() == Some(SessionEventKind::SessionUsage) {
match serde_json::from_value::<crate::tui::session_usage::SessionUsageRecord>(
event.payload.clone(),
) {
Ok(record) => {
if record.id.contains("/primary/") {
primary_usage_since_output = true;
}
ui_state.session_usage.observe(record);
}
Err(_) => ui_state.session_usage.incomplete = true,
}
ui_state.refresh_session_cache_percent();
}
}
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 pending_assistant_timestamp = None;
let mut projected_assistant_chunk_text = String::new();
let mut assistant_chunks_in_current_response = false;
let mut restored_terminal_assistant_text = false;
let mut prior_assistant_chunk_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::SessionUsage) => {}
Some(SessionEventKind::Compaction) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut projected_assistant_chunk_text,
&mut pending_assistant_timestamp,
);
if let Some(summary) = event
.payload
.get("summary")
.and_then(serde_json::Value::as_str)
{
ui_state.apply_compaction_history_summary_at(summary, Some(event.timestamp));
}
}
Some(SessionEventKind::UserInput) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut projected_assistant_chunk_text,
&mut pending_assistant_timestamp,
);
projected_assistant_chunk_text.clear();
prior_assistant_chunk_text.clear();
reasoning_group.reset();
reasoning_turn_sequence = reasoning_turn_sequence.saturating_add(1);
assistant_chunks_in_current_response = false;
restored_terminal_assistant_text = false;
if let Some(text) = event
.payload
.get("text")
.and_then(serde_json::Value::as_str)
{
let output_event = if event
.payload
.get("origin")
.and_then(serde_json::Value::as_str)
== Some("automatic_compaction")
{
OutputEvent::AutomaticUserPrompt {
text: text.to_string(),
}
} else {
OutputEvent::UserPrompt {
text: text.to_string(),
}
};
ui_state.apply_output_event_at(&output_event, Some(event.timestamp));
}
}
Some(SessionEventKind::AssistantChunk) => {
if let Some(text) = event
.payload
.get("text")
.and_then(serde_json::Value::as_str)
{
if !text.trim().is_empty() {
assistant_chunks_in_current_response = true;
}
if !text.is_empty() {
pending_assistant_timestamp.get_or_insert(event.timestamp);
}
pending_assistant_text.push_str(text);
}
}
Some(SessionEventKind::AssistantOutput) => {
if !recorded_primary_outputs.contains(&event_index) {
let raw = event.payload.get("usage");
let tokens = |key| {
raw.and_then(|value| value.get(key))
.and_then(serde_json::Value::as_u64)
.unwrap_or_default()
};
ui_state.session_usage.incomplete = true;
ui_state
.session_usage
.observe(crate::tui::session_usage::SessionUsageRecord {
id: format!("legacy/primary/{event_index}"),
usage: crate::output::NormalizedUsageSnapshot {
effective_input: tokens("input"),
output: tokens("output"),
cache_read: tokens("cache_read"),
cache_known: false,
},
request_sequence: 0,
final_usage: true,
});
}
let output_text = event
.payload
.get("text")
.and_then(serde_json::Value::as_str)
.unwrap_or(&pending_assistant_text)
.to_string();
pending_assistant_text.clear();
let assistant_timestamp = pending_assistant_timestamp
.take()
.unwrap_or(event.timestamp);
let cumulative_suffix = output_text.strip_prefix(&prior_assistant_chunk_text);
let text = if !prior_assistant_chunk_text.is_empty()
&& cumulative_suffix
.is_some_and(|suffix| suffix.starts_with(&projected_assistant_chunk_text))
{
cumulative_suffix.unwrap_or_default().to_string()
} else {
output_text
};
projected_assistant_chunk_text.clear();
prior_assistant_chunk_text.clear();
if !text.is_empty() {
ui_state.apply_output_event_at(
&OutputEvent::AssistantComplete { text },
Some(assistant_timestamp),
);
}
}
Some(SessionEventKind::ReasoningSummary) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut projected_assistant_chunk_text,
&mut pending_assistant_timestamp,
);
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_at(
&OutputEvent::ThinkingSummaryCompleteIdentified {
text: text.to_string(),
item_id: Some(item_id.to_string()),
turn_id: Some(turn_id),
},
Some(event.timestamp),
);
} 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_at(
&OutputEvent::ThinkingSummaryCompleteIdentified {
text: text.to_string(),
item_id: Some(item_id),
turn_id: Some(turn_id),
},
Some(event.timestamp),
);
}
}
}
Some(SessionEventKind::ToolCall) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut projected_assistant_chunk_text,
&mut pending_assistant_timestamp,
);
prior_assistant_chunk_text.push_str(&projected_assistant_chunk_text);
projected_assistant_chunk_text.clear();
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_at(
&OutputEvent::ToolStarted {
label: crate::output::tool_display_label(&call),
call: Box::new(display_call),
},
Some(event.timestamp),
);
hydrate_subagent_tool_call(&mut *ui_state, &activity_id, &call);
}
Some(SessionEventKind::ToolResult | SessionEventKind::ToolDisplayResult) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut projected_assistant_chunk_text,
&mut pending_assistant_timestamp,
);
prior_assistant_chunk_text.push_str(&projected_assistant_chunk_text);
projected_assistant_chunk_text.clear();
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)));
if call.name == tool_name::SUBAGENTS {
hydrate_subagent_tool_result(
&mut *ui_state,
&activity_id,
&call,
&result,
);
}
let mut display_call = call.clone();
display_call.id = activity_id.as_str().to_string();
ui_state.apply_output_event_at(
&OutputEvent::ToolResult {
changed_paths: Vec::new(),
summary: Box::new(crate::output::tool_display_summary(
&call, &result,
)),
call: Box::new(display_call),
result: Box::new(result),
},
Some(event.timestamp),
);
}
Err(message) => {
ui_state.apply_output_event_at(
&OutputEvent::Diagnostic {
level: "warning".to_string(),
message,
},
Some(event.timestamp),
);
}
}
}
Some(SessionEventKind::TurnStatus) => {
let Some(payload) = event.turn_status_payload() else {
continue;
};
if matches!(
payload.status,
TurnStatus::Cancelled | TurnStatus::Failed | TurnStatus::CompactionRequired
) && !assistant_chunks_in_current_response
&& pending_assistant_text.trim().is_empty()
&& !restored_terminal_assistant_text
&& let Some(text) = payload.assistant_text.as_deref()
&& !text.trim().is_empty()
{
ui_state.apply_output_event_at(
&OutputEvent::AssistantComplete {
text: text.to_string(),
},
Some(event.timestamp),
);
restored_terminal_assistant_text = true;
}
let status = match payload.status {
TurnStatus::Cancelled => Some(ActivityStatus::Canceled),
TurnStatus::Failed => Some(ActivityStatus::Failed),
_ => None,
};
if let Some(status) = status {
for (call_id, call) in &tool_calls {
if let Some(id) = tool_activity_ids.get(call_id) {
if call.name == tool_name::SUBAGENTS {
finish_outstanding_subagent_tasks(&mut *ui_state, id, status);
}
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 projected_assistant_chunk_text,
&mut pending_assistant_timestamp,
);
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 projected_assistant_chunk_text,
&mut pending_assistant_timestamp,
);
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 projected_assistant_chunk_text,
&mut pending_assistant_timestamp,
);
hydrate_diagnostic_event(ui_state, event);
}
Some(SessionEventKind::HookDiagnostic) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut projected_assistant_chunk_text,
&mut pending_assistant_timestamp,
);
hydrate_hook_diagnostic_event(ui_state, event);
}
Some(SessionEventKind::SubdirInstructionLoad) => {
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut projected_assistant_chunk_text,
&mut pending_assistant_timestamp,
);
hydrate_subdir_instruction_load_event(ui_state, event, &tool_activity_ids);
}
_ => {}
}
}
flush_pending_assistant_history(
ui_state,
&mut pending_assistant_text,
&mut projected_assistant_chunk_text,
&mut pending_assistant_timestamp,
);
ui_state.finish_assistant_streaming();
ui_state.session_usage.fold_completed_run();
}
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_at(
&OutputEvent::Diagnostic { level, message },
Some(event.timestamp),
);
}
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_at(
&OutputEvent::HookDiagnostic {
diagnostic: Box::new(diagnostic),
},
Some(event.timestamp),
);
}
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_at(
&OutputEvent::ProviderContextInjection {
metadata: display.clone(),
},
Some(event.timestamp),
);
}
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 {
parent_activity_id.child(format!(
"{phase}-provider-context-injection-{}",
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,
projected_assistant_chunk_text: &mut String,
pending_assistant_timestamp: &mut Option<chrono::DateTime<chrono::Utc>>,
) {
if !pending_assistant_text.is_empty() {
let text = std::mem::take(pending_assistant_text);
projected_assistant_chunk_text.push_str(&text);
let timestamp = pending_assistant_timestamp.take();
ui_state.apply_output_event_at(&OutputEvent::AssistantDelta { text }, timestamp);
} else {
pending_assistant_timestamp.take();
}
}
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_subagent_tool_call(
ui_state: &mut state::MissionControlState,
parent_id: &ActivityId,
call: &ToolCall,
) {
if call.name != tool_name::SUBAGENTS {
return;
}
let Some(arguments) = parse_bounded_subagents_args(&call.arguments) else {
return;
};
for (index, task) in arguments.tasks.iter().enumerate() {
let task_id = format!("g{}", index + 1);
ui_state.apply_activity_event(ActivityEvent::Started {
id: ActivityId::new(format!("{}/{}", parent_id.as_str(), task_id)),
parent_id: Some(parent_id.clone()),
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: hydrated_subagent_task_metadata(&task_id, task),
});
}
}
fn hydrate_subagent_tool_result(
ui_state: &mut state::MissionControlState,
parent_id: &ActivityId,
call: &ToolCall,
result: &ToolResult,
) {
let output = parse_bounded_subagents_output(&result.content);
if let Some(output) = output.as_ref() {
ensure_hydrated_subagent_parent(ui_state, parent_id, call);
for task_result in &output.results {
let Some(task_activity_id) =
hydrated_subagent_task_activity_id(parent_id, &task_result.id)
else {
continue;
};
if !ui_state.nodes.contains_key(&task_activity_id) {
ui_state.apply_activity_event(ActivityEvent::Started {
id: task_activity_id,
parent_id: Some(parent_id.clone()),
kind: ActivityKind::SubagentTask,
status: ActivityStatus::Running,
metadata: hydrated_subagent_result_metadata(task_result),
});
}
}
}
let task_ids = subagent_task_children(ui_state, parent_id);
if task_ids.is_empty() {
return;
}
let fallback_status = if result.success {
ActivityStatus::Success
} else {
ActivityStatus::Failed
};
let mut reconciled = std::collections::HashSet::new();
if let Some(output) = output {
for task_result in &output.results {
let Some(task_activity_id) =
hydrated_subagent_task_activity_id(parent_id, &task_result.id)
else {
continue;
};
if !task_ids.contains(&task_activity_id) || !reconciled.insert(task_activity_id.clone())
{
continue;
}
finish_hydrated_subagent_task(
ui_state,
task_activity_id,
Some(task_result),
fallback_status,
);
}
}
for task_activity_id in task_ids {
if !reconciled.contains(&task_activity_id) {
finish_hydrated_subagent_task(ui_state, task_activity_id, None, fallback_status);
}
}
}
fn ensure_hydrated_subagent_parent(
ui_state: &mut state::MissionControlState,
parent_id: &ActivityId,
call: &ToolCall,
) {
if ui_state.nodes.contains_key(parent_id) {
return;
}
ui_state.apply_activity_event(ActivityEvent::Started {
id: parent_id.clone(),
parent_id: None,
kind: ActivityKind::Tool,
status: crate::output::pending_activity_status(call),
metadata: ActivityMetadata::new(crate::output::tool_display_label(call)),
});
}
fn subagent_task_children(
ui_state: &state::MissionControlState,
parent_id: &ActivityId,
) -> Vec<ActivityId> {
ui_state
.children
.get(parent_id)
.cloned()
.unwrap_or_default()
.into_iter()
.filter(|id| {
ui_state
.nodes
.get(id)
.is_some_and(|node| node.kind == ActivityKind::SubagentTask)
})
.collect()
}
fn finish_outstanding_subagent_tasks(
ui_state: &mut state::MissionControlState,
parent_id: &ActivityId,
status: ActivityStatus,
) {
for id in subagent_task_children(ui_state, parent_id) {
let outstanding = ui_state.nodes.get(&id).is_some_and(|node| {
matches!(
node.status,
ActivityStatus::Queued | ActivityStatus::Running | ActivityStatus::Writing
)
});
if outstanding {
ui_state.apply_activity_event(ActivityEvent::Finished {
id,
status,
metadata: None,
});
}
}
}
fn finish_hydrated_subagent_task(
ui_state: &mut state::MissionControlState,
id: ActivityId,
result: Option<&SubagentTaskResult>,
fallback_status: ActivityStatus,
) {
let status = result
.map(subagent_result_activity_status)
.unwrap_or(fallback_status);
if let Some(result) = result {
if let Some(usage) = result.usage {
let request_sequence = usage.latest_request_sequence.unwrap_or_default();
let final_usage = usage.latest_final;
if !ui_state.session_usage.has_recorded_activity(id.as_str()) {
ui_state
.session_usage
.observe(crate::tui::session_usage::SessionUsageRecord {
id: format!("legacy/activity/{}", id.as_str()),
usage: usage.whole_run,
request_sequence,
final_usage,
});
}
ui_state.apply_activity_event(ActivityEvent::UsageSnapshot {
id: id.clone(),
usage,
request_sequence,
final_usage,
});
} else if !ui_state.session_usage.has_recorded_activity(id.as_str()) {
ui_state.session_usage.incomplete = true;
}
ui_state.apply_activity_event(ActivityEvent::FinalPreview {
id: id.clone(),
preview: subagent_result_preview(result),
metadata: None,
status: Some(status),
});
}
ui_state.apply_activity_event(ActivityEvent::Finished {
id,
status,
metadata: None,
});
}
fn parse_bounded_subagents_args(arguments: &serde_json::Value) -> Option<SubagentsArgs> {
let serialized = serde_json::to_vec(arguments).ok()?;
if serialized.len() > MAX_SUBAGENT_REPLAY_JSON_BYTES {
return None;
}
let arguments: SubagentsArgs = serde_json::from_slice(&serialized).ok()?;
arguments.validated_concurrency().ok()?;
Some(arguments)
}
fn parse_bounded_subagents_output(content: &str) -> Option<SubagentsOutput> {
if content.len() > MAX_SUBAGENT_REPLAY_JSON_BYTES {
return None;
}
let output: SubagentsOutput = serde_json::from_str(content).ok()?;
(output.results.len() <= MAX_SUBAGENT_TASKS).then_some(output)
}
fn hydrated_subagent_task_metadata(task_id: &str, task: &SubagentTask) -> ActivityMetadata {
hydrated_subagent_metadata(
task_id,
&task.intent,
task.identity.as_deref(),
task.agent.as_deref(),
)
}
fn hydrated_subagent_result_metadata(result: &SubagentTaskResult) -> ActivityMetadata {
hydrated_subagent_metadata(
&result.id,
&result.intent,
result.identity.as_deref(),
result.agent.as_deref(),
)
}
fn hydrated_subagent_metadata(
task_id: &str,
intent: &str,
identity: Option<&str>,
agent: Option<&str>,
) -> ActivityMetadata {
let intent = bounded_subagent_metadata_value(intent);
let intent = if intent.is_empty() {
"task"
} else {
intent.as_str()
};
let label = format!("{task_id} · depth {HYDRATED_SUBAGENT_DEPTH} · {intent}");
let mut metadata = ActivityMetadata::new(bounded_subagent_metadata_value(&label));
metadata
.fields
.push(("depth".to_string(), HYDRATED_SUBAGENT_DEPTH.to_string()));
if let Some(identity) = identity {
let identity = bounded_subagent_metadata_value(identity);
if !identity.is_empty() {
metadata.fields.push(("identity".to_string(), identity));
}
}
if let Some(agent) = agent {
let agent = bounded_subagent_metadata_value(agent);
if !agent.is_empty() {
metadata.fields.push(("agent".to_string(), agent));
}
}
metadata
}
fn bounded_subagent_metadata_value(value: &str) -> String {
crate::tui::activity::sanitize_and_bound_preview(value)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn hydrated_subagent_task_activity_id(parent_id: &ActivityId, task_id: &str) -> Option<ActivityId> {
let digits = task_id.strip_prefix('g')?;
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let index = digits.parse::<usize>().ok()?;
if !(1..=MAX_SUBAGENT_TASKS).contains(&index) || format!("g{index}") != task_id {
return None;
}
Some(ActivityId::new(format!(
"{}/{}",
parent_id.as_str(),
task_id
)))
}
fn subagent_result_activity_status(result: &SubagentTaskResult) -> ActivityStatus {
match &result.status {
SubagentStatus::Completed => ActivityStatus::Success,
SubagentStatus::Failed => ActivityStatus::Failed,
}
}
fn subagent_result_preview(result: &SubagentTaskResult) -> String {
let preview = match &result.status {
SubagentStatus::Completed => result.output.as_str(),
SubagentStatus::Failed => result
.error
.as_deref()
.filter(|error| !error.trim().is_empty())
.unwrap_or(result.output.as_str()),
};
crate::tui::activity::sanitize_and_bound_preview(preview)
}
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_at(
&OutputEvent::SubdirInstructionInjection {
path: std::path::PathBuf::from(&path),
bytes,
parent_activity_id: parent_activity_id.clone(),
},
Some(event.timestamp),
);
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_multiple_compaction_checkpoints_projects_distinct_cards() {
let temp = tempfile::TempDir::new().unwrap();
let event = |summary| {
crate::sessions::SessionEvent::new(
"compaction",
"hydrated-compactions".to_string(),
temp.path().to_path_buf(),
serde_json::json!({"summary": summary}),
)
};
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(
&mut ui_state,
&[event("first"), event("second")],
&InjectedContentSettings::default(),
);
let cards = crate::tui::transcript_cards::project_cards(&ui_state);
assert_eq!(cards.len(), 2);
assert!(cards.iter().all(|card| card.body != "0/0"));
}
#[test]
fn persisted_compaction_summary_is_intact_before_primary_tui_bounding() {
let temp = tempfile::TempDir::new().unwrap();
let manager = crate::sessions::SessionManager::new(temp.path().join("sessions"));
let session = manager.create().unwrap();
let summary = (0..600)
.map(|index| format!("第{index} 行 — persisted summary 😀"))
.collect::<Vec<_>>()
.join("\n");
crate::sessions::record_session_compaction(
&session,
temp.path(),
&summary,
"test-provider",
"test-model",
0,
)
.unwrap();
let events = session.read_events().unwrap();
let (checkpoint, diagnostics) =
crate::sessions::latest_valid_compaction_checkpoint(session.id(), &events);
assert!(diagnostics.is_empty(), "{diagnostics:?}");
let checkpoint = checkpoint.expect("persisted compaction checkpoint");
assert_eq!(checkpoint.summary, summary);
let mut ui_state = state::MissionControlState::default();
ui_state.apply_compaction_history_summary_at(&checkpoint.summary, None);
let cards = crate::tui::transcript_cards::project_cards(&ui_state);
assert_eq!(cards.len(), 1);
assert_eq!(
cards[0].role,
crate::tui::transcript_cards::TranscriptCardRole::Compaction
);
assert!(cards[0].body.len() <= 4 * 1024 + "complete • ".len());
assert!(cards[0].body.contains("… [truncated]"), "{}", cards[0].body);
}
#[test]
fn completion_only_compaction_event_creates_one_card() {
let mut ui_state = state::MissionControlState::default();
ui_state.apply_compaction_history_summary_at("summary", None);
let cards = crate::tui::transcript_cards::project_cards(&ui_state);
assert_eq!(cards.len(), 1);
assert_eq!(
cards[0].role,
crate::tui::transcript_cards::TranscriptCardRole::Compaction
);
}
#[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_reconciles_segment_output_after_diagnostic() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "assistant-diagnostic-session";
let event = |event_type, payload| {
crate::sessions::SessionEvent::new(
event_type,
session_id.to_string(),
temp.path().to_path_buf(),
payload,
)
};
let events = vec![
event("assistant_chunk", serde_json::json!({"text":"I'll read."})),
event(
"tool_call",
serde_json::json!({"id":"call_read","name":"read","arguments":{"paths":["src/lib.rs"]}}),
),
event(
"tool_result",
serde_json::json!({"call_id":"call_read","result":{"tool_name":"read","success":true,"content":"contents","metadata":{}}}),
),
event("assistant_chunk", serde_json::json!({"text":"Done."})),
event(
"diagnostic",
serde_json::json!({"level":"info","message":"provider metadata"}),
),
event("assistant_output", serde_json::json!({"text":"Done."})),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
assert_eq!(ui_state.transcript.len(), 4);
assert_eq!(ui_state.transcript[0], "assistant: I'll read.");
assert!(ui_state.transcript[1].starts_with("tool: "));
assert_eq!(ui_state.transcript[2], "assistant: Done.");
assert_eq!(ui_state.transcript[3], "info: provider metadata");
}
#[test]
fn hydrate_session_history_strips_prior_segment_from_cumulative_output() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "cumulative-assistant-session";
let event = |event_type, payload| {
crate::sessions::SessionEvent::new(
event_type,
session_id.to_string(),
temp.path().to_path_buf(),
payload,
)
};
let events = vec![
event("assistant_chunk", serde_json::json!({"text":"I'll read."})),
event(
"tool_call",
serde_json::json!({"id":"call_read","name":"read","arguments":{"paths":["src/lib.rs"]}}),
),
event(
"tool_result",
serde_json::json!({"call_id":"call_read","result":{"tool_name":"read","success":true,"content":"contents","metadata":{}}}),
),
event("assistant_chunk", serde_json::json!({"text":"Found it."})),
event(
"assistant_output",
serde_json::json!({"text":"I'll read.Found it."}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
assert_eq!(ui_state.transcript.len(), 3);
assert_eq!(ui_state.transcript[0], "assistant: I'll read.");
assert!(ui_state.transcript[1].starts_with("tool: "));
assert_eq!(ui_state.transcript[2], "assistant: Found it.");
}
#[test]
fn hydrate_session_history_handles_repeated_identical_cumulative_segments() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "repeated-cumulative-assistant-session";
let event = |event_type, payload| {
crate::sessions::SessionEvent::new(
event_type,
session_id.to_string(),
temp.path().to_path_buf(),
payload,
)
};
let events = vec![
event("assistant_chunk", serde_json::json!({"text":"Same."})),
event(
"tool_call",
serde_json::json!({"id":"call_read","name":"read","arguments":{"paths":["src/lib.rs"]}}),
),
event(
"tool_result",
serde_json::json!({"call_id":"call_read","result":{"tool_name":"read","success":true,"content":"contents","metadata":{}}}),
),
event("assistant_chunk", serde_json::json!({"text":"Same."})),
event("assistant_output", serde_json::json!({"text":"Same.Same."})),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
assert_eq!(ui_state.transcript.len(), 3);
assert_eq!(ui_state.transcript[0], "assistant: Same.");
assert!(ui_state.transcript[1].starts_with("tool: "));
assert_eq!(ui_state.transcript[2], "assistant: Same.");
}
#[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("first summary")
);
assert_eq!(
ui_state.nodes[&ActivityId::new("history/reasoning/2/legacy-2-3")]
.metadata
.detail
.as_deref(),
Some("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_accepts_legacy_canceled_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":"canceled","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_interrupted_tools_close_with_malformed_assistant_text() {
let temp = tempfile::TempDir::new().unwrap();
for (status, expected_status, expected_label) in [
("failed", ActivityStatus::Failed, "Status: FAILED"),
("cancelled", ActivityStatus::Canceled, "Status: CANCELED"),
] {
let session_id = format!("interrupted-{status}");
let events = vec![
crate::sessions::SessionEvent::new(
"tool_call",
session_id.clone(),
temp.path().to_path_buf(),
serde_json::json!({
"id":"call_interrupted",
"name":"write",
"arguments":{"path":"a.txt","content":"draft"}
}),
),
crate::sessions::SessionEvent::new(
"turn_status",
session_id,
temp.path().to_path_buf(),
serde_json::json!({
"status":status,
"assistant_text":{"not":"text"}
}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
let node = ui_state
.nodes
.get(&ActivityId::new("call_interrupted"))
.unwrap();
assert_eq!(node.status, expected_status, "{status}: {node:?}");
let detail = ui_state.selected_activity_text();
assert!(detail.contains(expected_label), "{status}: {detail}");
assert!(detail.contains("\"path\": \"a.txt\""), "{status}: {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()),
]
);
}
#[test]
fn hydrate_automatic_compaction_prompt_preserves_legacy_display_provenance() {
let temp = tempfile::TempDir::new().unwrap();
let mut event = crate::sessions::SessionEvent::new(
"user_input",
"automatic-prompt".to_string(),
temp.path().to_path_buf(),
serde_json::json!({
"text":"continue after compaction",
"origin":"automatic_compaction"
}),
);
event.timestamp =
chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 2, 3, 4, 5).unwrap();
let timestamp = event.timestamp;
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &[event], &InjectedContentSettings::default());
assert_eq!(
ui_state.transcript.get(0).unwrap().kind(),
&crate::tui::transcript::TranscriptEntryKind::UserPrompt { automatic: true }
);
let card = &crate::tui::transcript_cards::project_cards(&ui_state)[0];
assert_eq!(
card.role,
crate::tui::transcript_cards::TranscriptCardRole::Diagnostic
);
assert_eq!(card.body, "you [automatic]: continue after compaction");
assert!(
crate::tui::transcript_cards::visual_text(&ui_state)
.contains("you [automatic]: continue after compaction")
);
assert_eq!(
ui_state.transcript_timestamp(0),
Some(timestamp.with_timezone(&chrono::Local))
);
}
#[test]
fn hydrate_terminal_status_restores_chunkless_assistant_text_once() {
let temp = tempfile::TempDir::new().unwrap();
for wire_status in ["cancelled", "failed", "compaction_required"] {
let session_id = format!("terminal-{wire_status}");
let event = |event_type, payload| {
crate::sessions::SessionEvent::new(
event_type,
session_id.clone(),
temp.path().to_path_buf(),
payload,
)
};
let user = event("user_input", serde_json::json!({"text":"prompt"}));
let mut terminal = event(
"turn_status",
serde_json::json!({
"status":wire_status,
"assistant_text":"partial answer"
}),
);
terminal.timestamp =
chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 2, 3, 4, 6).unwrap();
let terminal_timestamp = terminal.timestamp;
let repeated = event(
"turn_status",
serde_json::json!({
"status":wire_status,
"assistant_text":"different duplicate"
}),
);
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(
&mut ui_state,
&[user, terminal, repeated],
&InjectedContentSettings::default(),
);
assert_eq!(
ui_state.transcript,
vec![
"you: prompt".to_string(),
"assistant: partial answer".to_string(),
],
"{wire_status}"
);
assert_eq!(
ui_state.transcript.get(1).unwrap().kind(),
&crate::tui::transcript::TranscriptEntryKind::Assistant
);
assert_eq!(
ui_state.transcript_timestamp(1),
Some(terminal_timestamp.with_timezone(&chrono::Local)),
"{wire_status}"
);
}
}
#[test]
fn hydrate_terminal_status_skips_existing_chunks_and_malformed_text() {
let temp = tempfile::TempDir::new().unwrap();
let event = |event_type, payload| {
crate::sessions::SessionEvent::new(
event_type,
"terminal-chunk".to_string(),
temp.path().to_path_buf(),
payload,
)
};
let mut with_chunk = state::MissionControlState::default();
hydrate_session_history(
&mut with_chunk,
&[
event("user_input", serde_json::json!({"text":"prompt"})),
event("assistant_chunk", serde_json::json!({"text":"streamed"})),
event(
"turn_status",
serde_json::json!({
"status":"failed",
"assistant_text":"fallback must not duplicate"
}),
),
],
&InjectedContentSettings::default(),
);
assert_eq!(
with_chunk.transcript,
vec!["you: prompt".to_string(), "assistant: streamed".to_string()]
);
let mut malformed = state::MissionControlState::default();
hydrate_session_history(
&mut malformed,
&[
event("user_input", serde_json::json!({"text":"prompt"})),
event(
"turn_status",
serde_json::json!({
"status":"failed",
"assistant_text":{"not":"text"}
}),
),
],
&InjectedContentSettings::default(),
);
assert_eq!(malformed.transcript, vec!["you: prompt".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()]);
}
#[test]
fn hydrate_session_history_restores_subagent_children_and_card_statuses() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "subagent-history";
let event = |event_type, payload| {
crate::sessions::SessionEvent::new(
event_type,
session_id.to_string(),
temp.path().to_path_buf(),
payload,
)
};
let output = serde_json::json!({
"summary": {"total": 2, "completed": 1, "failed": 1},
"results": [
{
"id": "g1",
"status": "completed",
"intent": "inspect hydration",
"agent": "reviewer",
"identity": null,
"cwd": temp.path().display().to_string(),
"session_id": null,
"session_path": null,
"changed_files": [],
"output": "child completed",
"output_truncated": false,
"error": null,
"usage": {
"whole_run": {
"effective_input": 18,
"output": 3,
"cache_read": 6,
"cache_known": true
},
"latest": {
"effective_input": 12,
"output": 2,
"cache_read": 4,
"cache_known": true
},
"latest_request_sequence": 7,
"latest_final": false
}
},
{
"id": "g2",
"status": "failed",
"intent": "check fallback",
"agent": null,
"identity": null,
"cwd": temp.path().display().to_string(),
"session_id": null,
"session_path": null,
"changed_files": [],
"output": "",
"output_truncated": false,
"error": "child failed"
}
]
});
let events = vec![
event(
"tool_call",
serde_json::json!({
"id": "call_subagents",
"name": tool_name::SUBAGENTS,
"arguments": {
"tasks": [
{
"intent": "inspect hydration",
"agent": "reviewer",
"identity": "frontend-dev",
"context": "private child context",
"cwd": null
},
{
"intent": "check fallback",
"agent": null,
"identity": null,
"context": null,
"cwd": null
}
],
"concurrency": 2
}
}),
),
event(
"tool_result",
serde_json::json!({
"call_id": "call_subagents",
"result": {
"tool_name": tool_name::SUBAGENTS,
"success": false,
"content": serde_json::to_string(&output).unwrap(),
"metadata": {}
}
}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
let parent = ActivityId::new("call_subagents");
let g1 = ActivityId::new("call_subagents/g1");
let g2 = ActivityId::new("call_subagents/g2");
assert_eq!(
ui_state.children.get(&parent),
Some(&vec![g1.clone(), g2.clone()])
);
assert_eq!(ui_state.nodes[&g1].kind, ActivityKind::SubagentTask);
assert_eq!(ui_state.nodes[&g1].status, ActivityStatus::Success);
assert_eq!(ui_state.nodes[&g1].preview, "child completed");
let expected_usage = crate::output::NormalizedUsageAggregate {
whole_run: crate::output::NormalizedUsageSnapshot {
effective_input: 18,
output: 3,
cache_read: 6,
cache_known: true,
},
latest: Some(crate::output::NormalizedUsageSnapshot {
effective_input: 12,
output: 2,
cache_read: 4,
cache_known: true,
}),
latest_request_sequence: Some(7),
latest_final: false,
};
assert_eq!(ui_state.nodes[&g1].usage, Some(expected_usage));
assert!(ui_state.nodes[&g2].usage.is_none());
assert_eq!(
ui_state.nodes[&g1].metadata.label,
"g1 · depth 1 · inspect hydration"
);
assert_eq!(
ui_state.nodes[&g1].metadata.fields[1],
("identity".to_string(), "frontend-dev".to_string())
);
assert_eq!(ui_state.nodes[&g2].status, ActivityStatus::Failed);
assert_eq!(ui_state.nodes[&g2].preview, "child failed");
assert!(ui_state.nodes[&g1].metadata.detail.is_none());
let card = &crate::tui::transcript_cards::project_cards(&ui_state)[0];
assert_eq!(
card.role,
crate::tui::transcript_cards::TranscriptCardRole::SubagentBatch
);
assert_eq!(card.children.len(), 2);
assert_eq!(card.children[0].task_id, "g1");
assert_eq!(
card.children[0].status,
crate::tui::transcript_cards::TranscriptCardStatus::Success
);
assert_eq!(card.children[1].task_id, "g2");
assert_eq!(
card.children[1].status,
crate::tui::transcript_cards::TranscriptCardStatus::Failed
);
let summary = card.subagent_card.as_ref().expect("subagent card summary");
assert_eq!(
(summary.total, summary.done, summary.failed, summary.running),
(2, 1, 1, 0)
);
assert!(!card.children[0].label.contains("private child context"));
let expected_totals = crate::output::NormalizedUsageSnapshot {
cache_known: false,
..expected_usage.whole_run
};
assert_eq!(ui_state.session_usage_totals(), expected_totals);
let mut journaled_events = events.clone();
journaled_events.push(event(
"session_usage",
serde_json::json!({
"id": "session-usage/run/activity/call_subagents/g1",
"usage": expected_usage.whole_run,
"request_sequence": 7,
"final_usage": false,
}),
));
let mut restored = state::MissionControlState::default();
hydrate_session_history(
&mut restored,
&journaled_events,
&InjectedContentSettings::default(),
);
assert_eq!(restored.session_usage_totals(), expected_totals);
}
#[test]
fn hydrate_session_history_falls_back_to_parent_status_for_malformed_subagent_output() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "malformed-subagent-history";
let event = |event_type, payload| {
crate::sessions::SessionEvent::new(
event_type,
session_id.to_string(),
temp.path().to_path_buf(),
payload,
)
};
let events = vec![
event(
"tool_call",
serde_json::json!({
"id": "call_subagents",
"name": tool_name::SUBAGENTS,
"arguments": {
"tasks": [{"intent": "recover child status"}],
"concurrency": 1
}
}),
),
event(
"tool_result",
serde_json::json!({
"call_id": "call_subagents",
"result": {
"tool_name": tool_name::SUBAGENTS,
"success": true,
"content": "not structured subagent output",
"metadata": {}
}
}),
),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
let child = ActivityId::new("call_subagents/g1");
assert_eq!(ui_state.nodes[&child].status, ActivityStatus::Success);
assert!(ui_state.nodes[&child].preview.is_empty());
let card = &crate::tui::transcript_cards::project_cards(&ui_state)[0];
assert_eq!(card.children.len(), 1);
assert_eq!(
card.children[0].status,
crate::tui::transcript_cards::TranscriptCardStatus::Success
);
}
#[test]
fn hydrate_session_history_finishes_subagent_children_on_terminal_turn_status() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "interrupted-subagent-history";
let event = |event_type, payload| {
crate::sessions::SessionEvent::new(
event_type,
session_id.to_string(),
temp.path().to_path_buf(),
payload,
)
};
for (turn_status, expected_status) in [
("cancelled", ActivityStatus::Canceled),
("failed", ActivityStatus::Failed),
] {
let events = vec![
event(
"tool_call",
serde_json::json!({
"id": "call_subagents",
"name": tool_name::SUBAGENTS,
"arguments": {
"tasks": [{"intent": "first child"}, {"intent": "second child"}],
"concurrency": 2
}
}),
),
event("turn_status", serde_json::json!({"status": turn_status})),
];
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
let parent = ActivityId::new("call_subagents");
assert_eq!(ui_state.nodes[&parent].status, expected_status);
for task_id in ["g1", "g2"] {
let child = ActivityId::new(format!("call_subagents/{task_id}"));
assert_eq!(ui_state.nodes[&child].status, expected_status);
}
}
}
#[test]
fn hydrate_session_history_restores_subagents_from_result_without_usable_call() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "result-only-subagent-history";
let event = |event_type, payload| {
crate::sessions::SessionEvent::new(
event_type,
session_id.to_string(),
temp.path().to_path_buf(),
payload,
)
};
let output = serde_json::json!({
"summary": {"total": 2, "completed": 1, "failed": 1},
"results": [
{
"id": "g1",
"status": "completed",
"intent": "recover child",
"agent": "reviewer",
"identity": "frontend-dev",
"cwd": "/private/child",
"session_id": "child-session",
"session_path": "/private/child/session.json",
"changed_files": ["/private/child/file"],
"output": "child completed",
"output_truncated": false,
"error": null
},
{
"id": "g2",
"status": "failed",
"intent": "recover failed child",
"agent": null,
"identity": null,
"cwd": "/private/child-2",
"session_id": null,
"session_path": "/private/child-2/session.json",
"changed_files": ["/private/child-2/file"],
"output": "",
"output_truncated": false,
"error": "child failed"
}
]
});
let output_content = serde_json::to_string(&output).unwrap();
for preceding_call in [
Some(serde_json::json!({
"id": "call_subagents",
"name": tool_name::SUBAGENTS,
"arguments": {"tasks": "malformed"}
})),
None,
] {
let mut events = Vec::new();
if let Some(preceding_call) = preceding_call {
events.push(event("tool_call", preceding_call));
}
events.push(event(
"tool_result",
serde_json::json!({
"call_id": "call_subagents",
"result": {
"tool_name": tool_name::SUBAGENTS,
"success": false,
"content": output_content.clone(),
"metadata": {}
}
}),
));
let mut ui_state = state::MissionControlState::default();
hydrate_session_history(&mut ui_state, &events, &InjectedContentSettings::default());
let parent = ActivityId::new("call_subagents");
let g1 = ActivityId::new("call_subagents/g1");
let g2 = ActivityId::new("call_subagents/g2");
assert_eq!(ui_state.nodes[&parent].kind, ActivityKind::Tool);
assert_eq!(
ui_state.children.get(&parent),
Some(&vec![g1.clone(), g2.clone()])
);
assert_eq!(ui_state.nodes[&g1].parent_id, Some(parent.clone()));
assert_eq!(ui_state.nodes[&g1].status, ActivityStatus::Success);
assert_eq!(ui_state.nodes[&g1].preview, "child completed");
assert_eq!(ui_state.nodes[&g2].status, ActivityStatus::Failed);
assert_eq!(ui_state.nodes[&g2].preview, "child failed");
assert_eq!(
ui_state.nodes[&g1].metadata.label,
"g1 · depth 1 · recover child"
);
assert_eq!(
ui_state.nodes[&g1].metadata.fields,
vec![
("depth".to_string(), "1".to_string()),
("identity".to_string(), "frontend-dev".to_string()),
("agent".to_string(), "reviewer".to_string()),
]
);
let link = ui_state
.transcript
.activity_links()
.values()
.next()
.expect("result transcript link");
assert_eq!(link.activity_id, parent);
assert_eq!(link.tool_name, tool_name::SUBAGENTS);
let card = &crate::tui::transcript_cards::project_cards(&ui_state)[0];
assert_eq!(
card.role,
crate::tui::transcript_cards::TranscriptCardRole::SubagentBatch
);
assert_eq!(card.children.len(), 2);
assert_eq!(
card.children
.iter()
.map(|child| child.status)
.collect::<Vec<_>>(),
vec![
crate::tui::transcript_cards::TranscriptCardStatus::Success,
crate::tui::transcript_cards::TranscriptCardStatus::Failed,
]
);
assert!(card.children.iter().all(|child| {
!child.label.contains("/private") && !child.body.contains("/private")
}));
assert!(
!ui_state
.transcript
.iter()
.any(|line| line.contains("/private"))
);
}
}
#[test]
fn hydrate_history_uses_event_timestamps_for_rows_and_pending_chunks() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "timestamp-history";
let event = |event_type: &str, payload: serde_json::Value, second: u32| {
let mut event = crate::sessions::SessionEvent::new(
event_type,
session_id.to_string(),
temp.path().to_path_buf(),
payload,
);
event.timestamp =
chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 2, 3, 4, second).unwrap();
event
};
let events = vec![
event("user_input", serde_json::json!({"text":"prompt"}), 1),
event("assistant_chunk", serde_json::json!({"text":"answer"}), 2),
event(
"diagnostic",
serde_json::json!({"level":"info","message":"boundary"}),
3,
),
];
let mut state = state::MissionControlState::default();
hydrate_session_history(&mut state, &events, &InjectedContentSettings::default());
assert_eq!(
state.transcript,
vec![
"you: prompt".to_string(),
"assistant: answer".to_string(),
"info: boundary".to_string(),
]
);
assert_eq!(
state.transcript_timestamp(0),
Some(events[0].timestamp.with_timezone(&chrono::Local))
);
assert_eq!(
state.transcript_timestamp(1),
Some(events[1].timestamp.with_timezone(&chrono::Local))
);
assert_eq!(
state.transcript_timestamp(2),
Some(events[2].timestamp.with_timezone(&chrono::Local))
);
}
}