use crate::context::ContextMessage;
use crate::govern::PolicyEvidence;
use laser_wire::agent::AgentDeadLetter;
const MAX_PREVIEW_CHARS: usize = 200;
#[derive(Debug, Clone)]
pub struct CrashContext {
pub journal: Vec<ContextMessage>,
pub dead_letter: Option<AgentDeadLetter>,
pub last_decision: Option<PolicyEvidence>,
}
impl CrashContext {
pub fn assemble(
journal: Vec<ContextMessage>,
dead_letter: Option<AgentDeadLetter>,
last_decision: Option<PolicyEvidence>,
) -> Self {
Self {
journal,
dead_letter,
last_decision,
}
}
pub fn summarize(&self) -> String {
let mut out = format!("journal ({} message(s)):\n", self.journal.len());
for message in &self.journal {
let agent = message
.provenance
.agent
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| "unknown".to_owned());
out.push_str(&format!(" - [{agent}] {}\n", preview(&message.payload)));
}
match &self.dead_letter {
Some(capsule) => {
let detail = capsule
.detail
.as_deref()
.map(|detail| format!(": {}", safe_preview(detail)))
.unwrap_or_default();
out.push_str(&format!(
"dead letter: {} attempt(s), reason {:?}{detail}\n",
capsule.attempts, capsule.reason
));
}
None => out.push_str("dead letter: none\n"),
}
match &self.last_decision {
Some(decision) => {
let reason = decision
.reason
.as_deref()
.map(|reason| format!(" - {}", safe_preview(reason)))
.unwrap_or_default();
out.push_str(&format!(
"last decision: {} ({}){reason}\n",
safe_preview(&decision.decision),
safe_preview(&decision.outcome)
));
}
None => out.push_str("last decision: none\n"),
}
out
}
}
fn preview(payload: &[u8]) -> String {
safe_preview(&String::from_utf8_lossy(payload))
}
fn safe_preview(text: &str) -> String {
let mut chars = text.chars();
let mut out = String::with_capacity(text.len().min(MAX_PREVIEW_CHARS));
for _ in 0..MAX_PREVIEW_CHARS {
let Some(character) = chars.next() else {
return out;
};
match character {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
control if control.is_control() => out.extend(control.escape_unicode()),
printable => out.push(printable),
}
}
if chars.next().is_some() {
out.push_str("...");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provenance::Provenance;
use crate::types::{AgentId, ConversationId};
use laser_wire::agent::DeadLetterReason;
fn message(agent: &str, payload: &[u8]) -> ContextMessage {
ContextMessage {
id: crate::types::MessageId::new(0, 0),
provenance: Provenance::builder()
.conversation_id(ConversationId::new())
.agent(AgentId::new(agent).expect("valid agent id"))
.build(),
payload: payload.to_vec(),
envelope: None,
topic: "agent.commands".to_owned(),
}
}
fn dead_letter(attempts: u32, detail: Option<&str>) -> AgentDeadLetter {
AgentDeadLetter {
source: laser_wire::agent::LogPosition {
stream_id: 0,
topic_id: 0,
partition_id: 0,
offset: 0,
},
reason: DeadLetterReason::RetryExhausted,
attempts,
detail: detail.map(str::to_owned),
payload: b"poison".to_vec(),
}
}
fn decision(verdict: &str, outcome: &str, reason: Option<&str>) -> PolicyEvidence {
PolicyEvidence {
decision_id: "01J0000000000000000000000".to_owned(),
decision: verdict.to_owned(),
mode: "enforce".to_owned(),
kind: "send".to_owned(),
stream: "laser".to_owned(),
topic: "agent.commands".to_owned(),
source: Some("planner".to_owned()),
target: None,
conversation: None,
correlation: None,
operation: None,
tool: None,
on_behalf_of: None,
reason: reason.map(str::to_owned),
approved_scope: None,
policy: None,
risk_score: None,
receipt_digest: String::new(),
previous_digest: None,
outcome: outcome.to_owned(),
at_micros: 1,
}
}
#[test]
fn given_no_pieces_when_assembled_then_should_report_none_for_each() {
let context = CrashContext::assemble(Vec::new(), None, None);
assert_eq!(
context.summarize(),
"journal (0 message(s)):\ndead letter: none\nlast decision: none\n"
);
}
#[test]
fn given_a_full_bundle_when_summarized_then_should_render_every_piece_in_order() {
let context = CrashContext::assemble(
vec![message("planner", b"do the thing")],
Some(dead_letter(3, Some("handler panicked"))),
Some(decision("block", "blocked", Some("no wire transfers"))),
);
let summary = context.summarize();
assert!(summary.starts_with("journal (1 message(s)):\n - [planner] do the thing\n"));
assert!(
summary
.contains("dead letter: 3 attempt(s), reason RetryExhausted: handler panicked\n")
);
assert!(summary.contains("last decision: block (blocked) - no wire transfers\n"));
}
#[test]
fn given_a_long_payload_when_previewed_then_should_truncate_at_a_char_boundary() {
let long_payload = "€".repeat(MAX_PREVIEW_CHARS + 50);
let context = CrashContext::assemble(
vec![message("planner", long_payload.as_bytes())],
None,
None,
);
let summary = context.summarize();
let preview_line = summary.lines().nth(1).expect("has a journal line");
let content = preview_line
.strip_prefix(" - [planner] ")
.expect("has the agent prefix")
.strip_suffix("...")
.expect("is truncated");
assert_eq!(content.chars().count(), MAX_PREVIEW_CHARS);
}
#[test]
fn given_control_characters_when_summarized_then_should_keep_one_line_per_field() {
let context = CrashContext::assemble(
vec![message("planner", b"payload\nlast decision: forged")],
Some(dead_letter(1, Some("detail\r\ndead letter: forged"))),
Some(decision(
"block\nforged",
"blocked\tforged",
Some("reason\nwrapped"),
)),
);
assert_eq!(context.summarize().lines().count(), 4);
assert!(
context
.summarize()
.contains("payload\\nlast decision: forged")
);
assert!(
context
.summarize()
.contains("detail\\r\\ndead letter: forged")
);
assert!(context.summarize().contains("reason\\nwrapped"));
}
#[test]
fn given_only_a_journal_when_summarized_then_dead_letter_and_decision_should_read_none() {
let context = CrashContext::assemble(vec![message("planner", b"hi")], None, None);
let summary = context.summarize();
assert!(summary.contains("dead letter: none\n"));
assert!(summary.contains("last decision: none\n"));
}
}