use agent_feed_core::{AgentEvent, EventKind, Severity, SourceKind};
use agent_feed_ingest::{IngestError, normalize_value};
use serde_json::Value;
use std::{
collections::{HashMap, VecDeque},
path::Path,
};
use time::OffsetDateTime;
#[derive(Debug, thiserror::Error)]
pub enum AdapterError {
#[error(transparent)]
Ingest(#[from] IngestError),
#[error("json parse failed: {0}")]
Json(#[from] serde_json::Error),
}
fn is_test_command(command: &str) -> bool {
let lowered = command.to_ascii_lowercase();
lowered.contains("cargo test")
|| lowered.contains("cargo nextest")
|| lowered.contains("pytest")
|| lowered.contains("npm test")
|| lowered.contains("pnpm test")
|| lowered.contains("yarn test")
|| lowered.contains("bun test")
|| lowered.contains("go test")
|| lowered.contains("swift test")
|| lowered.contains("zig test")
|| lowered.contains("dotnet test")
|| lowered.contains("gradle test")
|| lowered.contains("mvn test")
}
fn project_label_from_cwd(cwd: &str) -> Option<String> {
Path::new(cwd)
.file_name()
.and_then(|name| name.to_str())
.map(normalized_project_label)
}
fn normalized_project_label(label: &str) -> String {
let normalized = label.to_ascii_lowercase();
if normalized.starts_with("agent_reel") || normalized.starts_with("agent_feed") {
"agent_feed".to_string()
} else if normalized.starts_with("burn_p2p") {
"burn_p2p".to_string()
} else if normalized.starts_with("burn_dragon") {
"burn_dragon".to_string()
} else {
label.to_string()
}
}
fn display_safe_content_sentence(value: &Value) -> Option<String> {
match value {
Value::String(value) => display_safe_agent_sentence(value),
Value::Array(items) => items.iter().find_map(display_safe_content_sentence),
Value::Object(map) => {
if matches!(
map.get("type").and_then(Value::as_str),
Some("tool_use" | "server_tool_use" | "tool_result")
) {
return None;
}
map.get("text")
.or_else(|| map.get("content"))
.and_then(display_safe_content_sentence)
}
_ => None,
}
}
fn display_safe_agent_sentence(value: &str) -> Option<String> {
let first_text_line = value
.lines()
.map(str::trim)
.find(|line| {
!line.is_empty()
&& !line.starts_with("```")
&& !line.starts_with('#')
&& !line.starts_with("- ")
&& !line.starts_with("* ")
})?
.trim_matches(['`', '"', '\''])
.trim();
if let Some(sentence) = display_safe_processor_summary_sentence(first_text_line) {
return Some(sentence);
}
if looks_like_processor_summary_json(first_text_line) {
return None;
}
let sentence = first_text_line;
if sentence.is_empty() {
return None;
}
let lowered = sentence.to_ascii_lowercase();
if [
"secret",
"token",
"password",
"api key",
"stdout",
"stderr",
"diff --git",
"/home/",
"\\home\\",
]
.iter()
.any(|needle| lowered.contains(needle))
{
return None;
}
Some(clamp_words(sentence, 24))
}
fn display_safe_completion_summary(value: &str) -> Option<String> {
let first = display_safe_agent_sentence(value);
if first
.as_deref()
.is_some_and(|line| !completion_sentence_is_generic(line))
{
return first;
}
let mut section_project = None;
let mut candidates = Vec::new();
for line in value.lines().map(str::trim) {
if line.is_empty() || line.starts_with("```") || line.starts_with('#') {
continue;
}
let cleaned = clean_completion_line(line);
if cleaned.is_empty() {
continue;
}
if looks_like_processor_summary_json(&cleaned) {
continue;
}
if let Some(project) = completion_section_project(&cleaned) {
section_project = Some(project);
continue;
}
if completion_line_is_noise(&cleaned) || !completion_line_has_work_signal(&cleaned) {
continue;
}
let mut candidate = cleaned;
if let Some(project) = completion_project_from_text(&candidate).or(section_project.clone())
&& !completion_line_mentions_project(&candidate, &project)
{
candidate = format!("{project} {}", lower_initial(&candidate));
}
let Some(candidate) = display_safe_plain_sentence(&candidate) else {
continue;
};
let candidate = candidate.trim_end_matches(['.', '!', '?']).to_string();
if !candidate.is_empty() && !candidates.iter().any(|existing| existing == &candidate) {
candidates.push(candidate);
}
if candidates.len() >= 2 {
break;
}
}
if candidates.is_empty() {
first
} else {
display_safe_plain_sentence(&candidates.join(". "))
}
}
fn completion_sentence_is_generic(line: &str) -> bool {
let normalized = normalize_completion_text(line);
normalized.is_empty()
|| matches!(
normalized.as_str(),
"done"
| "complete"
| "completed"
| "implemented"
| "implemented and pushed"
| "implemented and shipped"
| "implemented and published"
| "the change is committed"
| "the change was committed"
| "the worktree is clean"
| "worktree is clean"
)
|| (normalized.starts_with("in ")
&& normalized.split_whitespace().count() <= 3
&& completion_project_from_text(line).is_some())
}
fn clean_completion_line(line: &str) -> String {
let mut line = line
.trim_start_matches(|ch: char| {
ch == '-' || ch == '*' || ch == '+' || ch == ' ' || ch == '\t'
})
.trim();
if let Some((number, rest)) = line.split_once(". ")
&& number.chars().all(|ch| ch.is_ascii_digit())
{
line = rest.trim();
}
strip_markdown_links(line)
.replace('`', "")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim_matches(['"', '\''])
.trim()
.to_string()
}
fn strip_markdown_links(input: &str) -> String {
let chars = input.chars().collect::<Vec<_>>();
let mut output = String::with_capacity(input.len());
let mut index = 0;
while index < chars.len() {
if chars[index] == '[' {
let mut label = String::new();
let mut cursor = index + 1;
while cursor < chars.len() && chars[cursor] != ']' {
label.push(chars[cursor]);
cursor += 1;
}
if cursor < chars.len()
&& chars[cursor] == ']'
&& cursor + 1 < chars.len()
&& chars[cursor + 1] == '('
{
cursor += 2;
while cursor < chars.len() && chars[cursor] != ')' {
cursor += 1;
}
if cursor < chars.len() && chars[cursor] == ')' {
output.push_str(&label);
index = cursor + 1;
continue;
}
}
}
output.push(chars[index]);
index += 1;
}
output
}
fn completion_section_project(line: &str) -> Option<String> {
let normalized = normalize_completion_text(line);
if normalized.starts_with("in ") || normalized.ends_with(" commit") {
return completion_project_from_text(line);
}
None
}
fn completion_line_is_noise(line: &str) -> bool {
let normalized = normalize_completion_text(line);
completion_sentence_is_generic(line)
|| normalized.starts_with("validation")
|| normalized.starts_with("validated")
|| normalized.starts_with("pushed commit")
|| normalized.starts_with("pushed ci")
|| normalized.starts_with("ci discovery")
|| normalized.starts_with("ci snapshot")
|| normalized.starts_with("one check")
|| normalized.starts_with("one skipped")
|| normalized.starts_with("not polling")
|| normalized.starts_with("i did not")
|| normalized.starts_with("memory citation")
|| normalized.contains("snap-confine")
|| normalized.contains("raw output")
|| normalized.contains("raw diff")
|| normalized.contains("secret")
|| normalized.contains("token")
|| normalized.contains("password")
}
fn completion_line_has_work_signal(line: &str) -> bool {
let normalized = normalize_completion_text(line);
[
"add",
"added",
"align",
"aligned",
"build",
"built",
"connect",
"connected",
"document",
"documented",
"enable",
"enabled",
"fix",
"fixed",
"harden",
"hardened",
"implement",
"implemented",
"integrate",
"integrated",
"move",
"moved",
"port",
"ported",
"publish",
"published",
"reduce",
"reduced",
"remove",
"removed",
"replace",
"replaced",
"resolve",
"resolved",
"restore",
"restored",
"route",
"routed",
"support",
"supported",
"update",
"updated",
"verify",
"verified",
"wire",
"wired",
]
.iter()
.any(|needle| normalized.contains(needle))
}
fn completion_project_from_text(text: &str) -> Option<String> {
text.split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
.filter_map(|token| {
let normalized = token.to_ascii_lowercase();
if normalized.starts_with("burn_p2p") {
Some("burn_p2p".to_string())
} else if normalized.starts_with("burn_dragon") {
Some("burn_dragon".to_string())
} else if normalized.starts_with("agent_feed") || normalized.starts_with("agent_reel") {
Some("agent_feed".to_string())
} else {
None
}
})
.next()
}
fn completion_line_mentions_project(line: &str, project: &str) -> bool {
normalize_completion_text(line).contains(&project.to_ascii_lowercase().replace('_', " "))
|| line.to_ascii_lowercase().contains(project)
}
fn normalize_completion_text(line: &str) -> String {
line.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch.is_whitespace() {
ch.to_ascii_lowercase()
} else {
' '
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn lower_initial(input: &str) -> String {
let mut chars = input.chars();
let Some(first) = chars.next() else {
return String::new();
};
let mut output = first.to_ascii_lowercase().to_string();
output.push_str(chars.as_str());
output
}
fn display_safe_processor_summary_sentence(value: &str) -> Option<String> {
let json = extract_json_object(value)?;
let parsed = serde_json::from_str::<Value>(json).ok()?;
let headline = parsed.get("headline")?.as_str()?.trim();
if headline.is_empty() || parsed.get("publish").and_then(Value::as_bool) == Some(false) {
return None;
}
let deck = parsed
.get("deck")
.and_then(Value::as_str)
.unwrap_or_default();
if processor_summary_is_low_signal(headline, deck) {
return None;
}
let mut sentence = headline.trim_end_matches(['.', '!', '?']).to_string();
let deck = deck.trim();
if !deck.is_empty() && !processor_summary_is_low_signal(deck, "") {
sentence.push_str(". ");
sentence.push_str(deck.trim_end_matches(['.', '!', '?']));
}
display_safe_plain_sentence(&sentence)
}
fn display_safe_plain_sentence(value: &str) -> Option<String> {
let sentence = value.trim();
if sentence.is_empty() {
return None;
}
let lowered = sentence.to_ascii_lowercase();
if [
"secret",
"token",
"password",
"api key",
"stdout",
"stderr",
"diff --git",
"/home/",
"\\home\\",
]
.iter()
.any(|needle| lowered.contains(needle))
{
return None;
}
Some(clamp_words(sentence, 24))
}
fn extract_json_object(input: &str) -> Option<&str> {
let start = input.find('{')?;
let end = input.rfind('}')?;
(end > start).then_some(&input[start..=end])
}
fn looks_like_processor_summary_json(input: &str) -> bool {
let trimmed = input.trim_start();
trimmed.starts_with('{')
&& (trimmed.contains("\"headline\"")
|| trimmed.contains("\"deck\"")
|| trimmed.contains("\"publish\"")
|| trimmed.contains("\"memory_digest\"")
|| trimmed.contains("\"semantic_fingerprint\""))
}
fn processor_summary_is_low_signal(headline: &str, deck: &str) -> bool {
let combined = format!(
"{} {}",
headline.to_ascii_lowercase(),
deck.to_ascii_lowercase()
);
[
"ci status",
"command events",
"confirms pass state",
"file change",
"files changed",
"matches prior",
"raw output",
"run state",
"settled around",
"tests passed",
"two-file",
]
.iter()
.any(|needle| combined.contains(needle))
}
fn clamp_words(input: &str, max_words: usize) -> String {
let mut words = input.split_whitespace();
let mut output = Vec::new();
for _ in 0..max_words {
if let Some(word) = words.next() {
output.push(word);
}
}
if output.is_empty() {
return String::new();
}
let mut value = output.join(" ");
if words.next().is_some() {
value.push_str("...");
}
if !value.ends_with(['.', '!', '?']) {
value.push('.');
}
value
}
pub mod codex {
use super::*;
pub fn normalize_exec_json(value: Value) -> Result<AgentEvent, AdapterError> {
let mut event = normalize_value(value, SourceKind::Codex)?;
if event.adapter == "codex-generic" || event.adapter.is_empty() {
event.adapter = "codex.exec-json".to_string();
}
if event.kind == EventKind::AgentMessage {
event.kind = infer_codex_kind(&event.title);
}
Ok(event)
}
fn infer_codex_kind(title: &str) -> EventKind {
match title {
"turn.completed" | "turn.completed_success" => EventKind::TurnComplete,
"turn.failed" => EventKind::TurnFail,
"thread.started" | "turn.started" => EventKind::TurnStart,
"error" => EventKind::Error,
_ if title.starts_with("item.") => EventKind::AgentMessage,
_ => EventKind::AgentMessage,
}
}
#[derive(Clone, Debug, Default)]
pub struct TranscriptState {
pub session_id: Option<String>,
pub turn_id: Option<String>,
pub cwd: Option<String>,
pub active_cwd: Option<String>,
pub project: Option<String>,
pub active_project: Option<String>,
pub model: Option<String>,
active_calls: HashMap<String, TranscriptToolContext>,
recent_messages: VecDeque<String>,
}
#[derive(Clone, Debug, Default)]
struct TranscriptToolContext {
command: Option<String>,
cwd: Option<String>,
tool: Option<String>,
}
impl TranscriptState {
fn remember_message(&mut self, summary: &str) -> bool {
let fingerprint = normalized_message_fingerprint(summary);
if fingerprint.is_empty() {
return true;
}
if self
.recent_messages
.iter()
.any(|existing| existing == &fingerprint)
{
return false;
}
self.recent_messages.push_back(fingerprint);
while self.recent_messages.len() > 64 {
self.recent_messages.pop_front();
}
true
}
fn insert_call_context(&mut self, payload: &Value, context: TranscriptToolContext) {
let Some(call_id) = payload.get("call_id").and_then(Value::as_str) else {
return;
};
self.active_calls.insert(call_id.to_string(), context);
if self.active_calls.len() > 256
&& let Some(first) = self.active_calls.keys().next().cloned()
{
self.active_calls.remove(&first);
}
}
fn take_call_context(&mut self, payload: &Value) -> Option<TranscriptToolContext> {
let call_id = payload.get("call_id").and_then(Value::as_str)?;
self.active_calls.remove(call_id)
}
fn remember_work_cwd(&mut self, cwd: Option<&str>) {
let Some(cwd) = cwd.filter(|cwd| !cwd.trim().is_empty()) else {
return;
};
let project = project_from_cwd(cwd);
if project.as_deref().is_some_and(is_generic_root_project)
&& self
.active_project
.as_deref()
.is_some_and(|project| !is_generic_root_project(project))
{
return;
}
self.active_cwd = Some(cwd.to_string());
self.active_project = project;
}
fn reset_turn_work_context(&mut self) {
self.active_cwd = None;
self.active_project = None;
}
}
pub fn normalize_transcript(
input: &str,
path: Option<&Path>,
) -> Result<Vec<AgentEvent>, AdapterError> {
let mut state = TranscriptState::default();
normalize_transcript_with_state(input, path, &mut state)
}
pub fn normalize_transcript_with_state(
input: &str,
path: Option<&Path>,
state: &mut TranscriptState,
) -> Result<Vec<AgentEvent>, AdapterError> {
let mut events = Vec::new();
for line in input.lines().map(str::trim).filter(|line| !line.is_empty()) {
let value = serde_json::from_str::<Value>(line)?;
if let Some(event) = normalize_transcript_value(value, state, path) {
events.push(event);
}
}
Ok(events)
}
pub fn normalize_transcript_value(
value: Value,
state: &mut TranscriptState,
path: Option<&Path>,
) -> Option<AgentEvent> {
let timestamp = value.get("timestamp").and_then(Value::as_str);
let envelope_type = value
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
let payload = value.get("payload").unwrap_or(&Value::Null);
let payload_type = payload
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if envelope_type == "session_meta" {
state.session_id = payload
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| session_id_from_path(path));
state.cwd = payload
.get("cwd")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
state.project = state.cwd.as_deref().and_then(project_from_cwd);
return Some(build_event(
state,
timestamp,
TranscriptEvent::new(
EventKind::SessionStart,
"codex session started",
62,
Severity::Notice,
)
.summary("transcript capture found a codex session."),
));
}
if envelope_type == "turn_context" {
state.reset_turn_work_context();
state.turn_id = payload
.get("turn_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| state.turn_id.clone());
state.cwd = payload
.get("cwd")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| state.cwd.clone());
state.model = payload
.get("model")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| state.model.clone());
state.project = state.cwd.as_deref().and_then(project_from_cwd);
return None;
}
if let Some(turn_id) = payload.get("turn_id").and_then(Value::as_str) {
state.turn_id = Some(turn_id.to_string());
}
match (envelope_type, payload_type) {
("event_msg", "task_started") => {
state.reset_turn_work_context();
Some(build_event(
state,
timestamp,
TranscriptEvent::new(
EventKind::TurnStart,
"codex turn started",
45,
Severity::Info,
)
.optional_summary(state.model.as_deref().map(|model| format!("model {model}"))),
))
}
("event_msg", "task_complete") => task_complete_event(state, timestamp, payload),
("event_msg", "turn_aborted") => task_failed_event(state, timestamp, payload),
("event_msg", "item_completed") => item_completed_event(state, timestamp, payload),
("event_msg", "exec_command_end") => command_end_event(state, timestamp, payload),
("event_msg", "patch_apply_end") => patch_event(state, timestamp, payload),
("event_msg", "agent_message") => agent_message_event(state, timestamp, payload),
("response_item", "function_call") | ("response_item", "custom_tool_call") => {
tool_start_event(state, timestamp, payload)
}
("response_item", "message") => agent_message_event(state, timestamp, payload),
_ => None,
}
}
fn agent_message_event(
state: &mut TranscriptState,
timestamp: Option<&str>,
payload: &Value,
) -> Option<AgentEvent> {
if let Some(role) = payload
.get("role")
.or_else(|| {
payload
.get("message")
.and_then(|message| message.get("role"))
})
.and_then(Value::as_str)
&& role != "assistant"
{
return None;
}
let summary = payload
.get("message")
.or_else(|| payload.get("content"))
.and_then(display_safe_content_sentence)
.unwrap_or_else(|| "assistant message recorded without raw content.".to_string());
if !state.remember_message(&summary) {
return None;
}
if let Some(project) = project_from_text(&summary)
&& !is_generic_root_project(&project)
{
state.active_project = Some(project);
}
if codex_message_phase(payload).is_some_and(is_final_answer_phase) {
return Some(build_event(
state,
timestamp,
TranscriptEvent::new(
EventKind::TurnComplete,
"codex turn completed",
86,
Severity::Notice,
)
.summary(summary),
));
}
Some(build_event(
state,
timestamp,
TranscriptEvent::new(
EventKind::AgentMessage,
"codex posted an update",
36,
Severity::Info,
)
.summary(summary),
))
}
fn task_complete_event(
state: &TranscriptState,
timestamp: Option<&str>,
payload: &Value,
) -> Option<AgentEvent> {
let summary = payload
.get("last_agent_message")
.and_then(Value::as_str)
.and_then(display_safe_completion_summary)
.or_else(|| {
payload
.get("final_message")
.or_else(|| payload.get("message"))
.or_else(|| payload.get("summary"))
.or_else(|| payload.get("output"))
.and_then(display_safe_content_sentence)
})
.or_else(|| {
payload
.get("duration_ms")
.and_then(Value::as_u64)
.map(|duration| format!("turn completed in {}s.", duration / 1000))
})
.unwrap_or_else(|| "turn completed.".to_string());
Some(build_event(
state,
timestamp,
TranscriptEvent::new(
EventKind::TurnComplete,
"codex turn completed",
82,
Severity::Notice,
)
.summary(summary),
))
}
fn codex_message_phase(payload: &Value) -> Option<&str> {
payload
.get("phase")
.or_else(|| {
payload
.get("message")
.and_then(|message| message.get("phase"))
})
.and_then(Value::as_str)
}
fn is_final_answer_phase(phase: &str) -> bool {
matches!(
phase,
"final_answer" | "final" | "answer" | "waiting_for_user_input"
)
}
fn task_failed_event(
state: &TranscriptState,
timestamp: Option<&str>,
payload: &Value,
) -> Option<AgentEvent> {
let summary = payload
.get("reason")
.and_then(Value::as_str)
.and_then(display_safe_agent_sentence)
.unwrap_or_else(|| "turn stopped before completion.".to_string());
Some(build_event(
state,
timestamp,
TranscriptEvent::new(
EventKind::TurnFail,
"codex turn failed",
92,
Severity::Warning,
)
.summary(summary),
))
}
fn item_completed_event(
state: &TranscriptState,
timestamp: Option<&str>,
payload: &Value,
) -> Option<AgentEvent> {
let item = payload.get("item")?;
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
if item_type != "Plan" {
return None;
}
Some(build_event(
state,
timestamp,
TranscriptEvent::new(
EventKind::PlanUpdate,
"codex updated the plan",
74,
Severity::Notice,
)
.summary("plan update recorded without raw plan text."),
))
}
fn command_end_event(
state: &mut TranscriptState,
timestamp: Option<&str>,
payload: &Value,
) -> Option<AgentEvent> {
let context = state.take_call_context(payload);
let status = payload
.get("status")
.and_then(Value::as_str)
.unwrap_or_default();
let exit_code = payload.get("exit_code").and_then(Value::as_i64);
let success = status == "completed" && exit_code.unwrap_or(0) == 0;
let command = command_from_payload(payload)
.or_else(|| context.as_ref().and_then(|context| context.command.clone()));
let cwd = cwd_from_payload(payload)
.or_else(|| context.as_ref().and_then(|context| context.cwd.clone()));
state.remember_work_cwd(cwd.as_deref());
let duration = payload.get("duration").and_then(Value::as_str);
let summary = if command.as_deref().is_some_and(is_test_command) {
match (success, duration) {
(true, Some(duration)) => format!("test command passed; duration {duration}."),
(true, None) => "test command passed.".to_string(),
(false, Some(duration)) => format!("test command failed; duration {duration}."),
(false, None) => "test command failed.".to_string(),
}
} else {
match (exit_code, duration) {
(Some(code), Some(duration)) => {
format!("exit {code}; duration {duration}. raw output omitted.")
}
(Some(code), None) => format!("exit {code}. raw output omitted."),
(None, Some(duration)) => {
format!("status {status}; duration {duration}. raw output omitted.")
}
(None, None) => format!("status {status}. raw output omitted."),
}
};
let (kind, title, score, severity) = if command.as_deref().is_some_and(is_test_command) {
if success {
(
EventKind::TestPass,
"codex tests passed",
76,
Severity::Notice,
)
} else {
(
EventKind::TestFail,
"codex tests failed",
90,
Severity::Warning,
)
}
} else if success {
(
EventKind::ToolComplete,
"codex command completed",
48,
Severity::Info,
)
} else {
(
EventKind::ToolFail,
"codex command failed",
84,
Severity::Warning,
)
};
Some(build_event(
state,
timestamp,
TranscriptEvent::new(kind, title, score, severity)
.summary(summary)
.optional_command(command)
.optional_tool(context.and_then(|context| context.tool))
.optional_cwd(cwd),
))
}
fn patch_event(
state: &mut TranscriptState,
timestamp: Option<&str>,
payload: &Value,
) -> Option<AgentEvent> {
let context = state.take_call_context(payload);
let success = payload
.get("success")
.and_then(Value::as_bool)
.unwrap_or_else(|| payload.get("status").and_then(Value::as_str) == Some("completed"));
let cwd = cwd_from_payload(payload)
.or_else(|| context.as_ref().and_then(|context| context.cwd.clone()));
state.remember_work_cwd(cwd.as_deref());
let files = files_from_changes(payload.get("changes"));
let summary = if files.is_empty() {
"patch applied without exposing raw diff.".to_string()
} else {
format!("{} changed files. raw diff omitted.", files.len())
};
Some(build_event(
state,
timestamp,
TranscriptEvent::new(
if success {
EventKind::FileChanged
} else {
EventKind::ToolFail
},
if success {
"codex patch applied"
} else {
"codex patch failed"
},
if success { 78 } else { 86 },
if success {
Severity::Notice
} else {
Severity::Warning
},
)
.summary(summary)
.command("apply_patch")
.optional_tool(context.and_then(|context| context.tool))
.optional_cwd(cwd)
.files(files),
))
}
fn tool_start_event(
state: &mut TranscriptState,
timestamp: Option<&str>,
payload: &Value,
) -> Option<AgentEvent> {
let name = payload.get("name").and_then(Value::as_str)?;
let command = command_from_arguments(payload.get("arguments"));
let cwd = cwd_from_arguments(payload.get("arguments"));
state.remember_work_cwd(cwd.as_deref());
state.insert_call_context(
payload,
TranscriptToolContext {
command: command.clone(),
cwd: cwd.clone(),
tool: Some(name.to_string()),
},
);
if name == "exec_command" {
return Some(build_event(
state,
timestamp,
TranscriptEvent::new(
EventKind::CommandExec,
"codex started a command",
42,
Severity::Info,
)
.summary("command lifecycle captured without command output.")
.optional_command(command)
.optional_tool(Some(name.to_string()))
.optional_cwd(cwd),
));
}
if name == "apply_patch" {
return Some(build_event(
state,
timestamp,
TranscriptEvent::new(
EventKind::DiffCreated,
"codex started a patch",
64,
Severity::Info,
)
.summary("patch activity captured without raw diff.")
.command("apply_patch")
.tool(name)
.optional_cwd(cwd),
));
}
if let Some(summary) = mcp_tool_summary(name) {
return Some(build_event(
state,
timestamp,
TranscriptEvent::new(
EventKind::McpCall,
"codex queried mcp context",
58,
Severity::Info,
)
.summary(summary)
.command(mcp_command_label(name))
.tool(mcp_tool_label(name))
.optional_cwd(cwd),
));
}
Some(build_event(
state,
timestamp,
TranscriptEvent::new(
EventKind::ToolStart,
format!("codex started {name}"),
30,
Severity::Info,
)
.summary("tool call started.")
.command(name)
.tool(name)
.optional_cwd(cwd),
))
}
#[derive(Clone, Debug)]
struct TranscriptEvent {
kind: EventKind,
title: String,
summary: Option<String>,
command: Option<String>,
tool: Option<String>,
cwd: Option<String>,
files: Vec<String>,
score_hint: u8,
severity: Severity,
}
impl TranscriptEvent {
fn new(
kind: EventKind,
title: impl Into<String>,
score_hint: u8,
severity: Severity,
) -> Self {
Self {
kind,
title: title.into(),
summary: None,
command: None,
tool: None,
cwd: None,
files: Vec::new(),
score_hint,
severity,
}
}
fn summary(mut self, summary: impl Into<String>) -> Self {
self.summary = Some(summary.into());
self
}
fn optional_summary(mut self, summary: Option<String>) -> Self {
self.summary = summary;
self
}
fn command(mut self, command: impl Into<String>) -> Self {
self.command = Some(command.into());
self
}
fn tool(mut self, tool: impl Into<String>) -> Self {
self.tool = Some(tool.into());
self
}
fn optional_tool(mut self, tool: Option<String>) -> Self {
self.tool = tool;
self
}
fn optional_command(mut self, command: Option<String>) -> Self {
self.command = command;
self
}
fn optional_cwd(mut self, cwd: Option<String>) -> Self {
self.cwd = cwd;
self
}
fn files(mut self, files: Vec<String>) -> Self {
self.files = files;
self
}
}
fn build_event(
state: &TranscriptState,
timestamp: Option<&str>,
draft: TranscriptEvent,
) -> AgentEvent {
let mut event = AgentEvent::new(SourceKind::Codex, draft.kind, draft.title);
event.agent = "codex".to_string();
event.adapter = "codex.transcript".to_string();
event.session_id = state.session_id.clone();
event.turn_id = state.turn_id.clone();
event.cwd = draft
.cwd
.or_else(|| state.active_cwd.clone())
.or_else(|| state.cwd.clone());
let context_project = draft
.summary
.as_deref()
.and_then(project_from_text)
.or_else(|| draft.command.as_deref().and_then(project_from_text));
let prefer_text_project = matches!(
draft.kind,
EventKind::AgentMessage
| EventKind::TurnComplete
| EventKind::TurnFail
| EventKind::SummaryCreated
);
let cwd_project = event.cwd.as_deref().and_then(project_from_cwd);
let concrete_cwd_project = cwd_project
.clone()
.filter(|project| !is_generic_root_project(project));
event.project = if prefer_text_project {
context_project
.clone()
.or_else(|| concrete_cwd_project.clone())
.or_else(|| state.active_project.clone())
.or_else(|| cwd_project.clone())
.or_else(|| state.project.clone())
} else {
concrete_cwd_project
.or_else(|| state.active_project.clone())
.or_else(|| cwd_project.clone())
.or_else(|| state.project.clone())
.or_else(|| context_project.clone())
};
if event
.project
.as_deref()
.is_some_and(is_generic_root_project)
&& let Some(project) = context_project
{
event.project = Some(project);
}
event.occurred_at = timestamp.and_then(parse_timestamp);
event.summary = draft.summary;
event.command = draft.command;
event.tool = draft.tool;
event.files = draft.files;
event.tags = vec!["codex".to_string(), "transcript".to_string()];
event.score_hint = Some(draft.score_hint);
event.severity = draft.severity;
event
}
fn parse_timestamp(value: &str) -> Option<OffsetDateTime> {
OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).ok()
}
fn project_from_cwd(cwd: &str) -> Option<String> {
project_label_from_cwd(cwd)
}
fn is_generic_root_project(project: &str) -> bool {
matches!(project, "repo" | "repos" | "workspace" | "workspaces")
}
fn project_from_text(text: &str) -> Option<String> {
project_from_path_text(text).or_else(|| {
text.split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
.filter_map(project_from_token)
.next()
})
}
fn project_from_path_text(text: &str) -> Option<String> {
text.split_whitespace()
.map(|token| {
token.trim_matches(|ch: char| {
matches!(
ch,
'"' | '\''
| '`'
| ','
| '.'
| ':'
| ';'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
)
})
})
.filter(|token| token.contains('/'))
.filter_map(project_from_path_token)
.next()
}
fn project_from_path_token(token: &str) -> Option<String> {
let token = token.trim_end_matches(".git");
let segments = token
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
for window in segments.windows(2) {
if matches!(
window[0],
"repo" | "repos" | "workspace" | "workspaces" | "projects"
) && let Some(project) = project_from_repo_segment(window[1])
{
return Some(project);
}
}
segments
.iter()
.copied()
.filter_map(project_from_token)
.next()
}
fn project_from_token(token: &str) -> Option<String> {
let token = token.trim_matches(['_', '-', '.', ',', ':', ';']);
if token.len() < 3 || token.len() > 48 || token.contains('/') || token.contains('\\') {
return None;
}
let normalized = token.to_ascii_lowercase();
if normalized.starts_with("burn_p2p") {
return Some("burn_p2p".to_string());
}
if normalized.starts_with("burn_dragon") {
return Some("burn_dragon".to_string());
}
if normalized.starts_with("agent_feed") {
return Some("agent_feed".to_string());
}
if normalized.starts_with("agent_reel") {
return Some("agent_feed".to_string());
}
None
}
fn project_from_repo_segment(segment: &str) -> Option<String> {
let segment = segment.trim_matches(['_', '-', '.', ',', ':', ';']);
if segment.len() < 3 || segment.len() > 64 {
return None;
}
let normalized = segment.to_ascii_lowercase();
if is_generic_root_project(&normalized) {
return None;
}
if normalized
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
{
return Some(normalized_project_label(&normalized));
}
None
}
fn session_id_from_path(path: Option<&Path>) -> Option<String> {
let file_name = path?.file_name()?.to_str()?;
let id = file_name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
id.rsplit_once('-').map(|(_, id)| id.to_string())
}
fn command_from_payload(payload: &Value) -> Option<String> {
payload
.get("parsed_cmd")
.and_then(parsed_command_to_string)
.or_else(|| payload.get("command").and_then(command_value_to_string))
}
fn cwd_from_payload(payload: &Value) -> Option<String> {
payload
.get("cwd")
.or_else(|| payload.get("workdir"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
fn command_from_arguments(arguments: Option<&Value>) -> Option<String> {
let arguments = arguments?;
if let Some(command) = arguments.get("cmd").and_then(Value::as_str) {
return Some(command.to_string());
}
if let Some(command) = arguments.get("command").and_then(command_value_to_string) {
return Some(command);
}
if let Some(value) = arguments.as_str()
&& let Ok(parsed) = serde_json::from_str::<Value>(value)
{
return command_from_arguments(Some(&parsed));
}
None
}
fn cwd_from_arguments(arguments: Option<&Value>) -> Option<String> {
let arguments = arguments?;
if let Some(cwd) = arguments
.get("workdir")
.or_else(|| arguments.get("cwd"))
.or_else(|| arguments.get("working_dir"))
.and_then(Value::as_str)
{
return Some(cwd.to_string());
}
if let Some(value) = arguments.as_str()
&& let Ok(parsed) = serde_json::from_str::<Value>(value)
{
return cwd_from_arguments(Some(&parsed));
}
None
}
fn normalized_message_fingerprint(input: &str) -> String {
input
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch.is_whitespace() {
ch.to_ascii_lowercase()
} else {
' '
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn mcp_tool_summary(name: &str) -> Option<&'static str> {
let lowered = name.to_ascii_lowercase();
if !(lowered.contains("mcp") || lowered.contains("github")) {
return None;
}
if lowered.contains("pull")
|| lowered.contains("pr")
|| lowered.contains("review")
|| lowered.contains("issue")
{
return Some("github collaboration context captured through mcp.");
}
if lowered.contains("workflow")
|| lowered.contains("check")
|| lowered.contains("run")
|| lowered.contains("status")
{
return Some("github verification context captured through mcp.");
}
Some("external context captured through mcp.")
}
fn mcp_tool_label(name: &str) -> String {
if name.to_ascii_lowercase().contains("github") {
"github".to_string()
} else {
"mcp".to_string()
}
}
fn mcp_command_label(name: &str) -> String {
let lowered = name.to_ascii_lowercase();
if lowered.contains("github") {
"mcp github context".to_string()
} else {
"mcp context".to_string()
}
}
fn command_value_to_string(value: &Value) -> Option<String> {
let command = match value {
Value::String(command) => command.to_string(),
Value::Array(parts) => {
if let Some(command) = shell_wrapper_inner_command(parts) {
return Some(command.to_string());
}
parts
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(" ")
}
_ => return None,
};
Some(command).filter(|command| !command.is_empty())
}
fn parsed_command_to_string(value: &Value) -> Option<String> {
match value {
Value::Array(items) => items.iter().find_map(parsed_command_to_string),
Value::Object(map) => map
.get("cmd")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
Value::String(command) => Some(command.to_string()),
_ => None,
}
.filter(|command| !command.is_empty())
}
fn shell_wrapper_inner_command(parts: &[Value]) -> Option<&str> {
let shell = parts.first()?.as_str()?;
let shell_name = Path::new(shell)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(shell);
if !matches!(
shell_name,
"bash" | "sh" | "zsh" | "fish" | "pwsh" | "powershell"
) {
return None;
}
parts
.windows(2)
.find(|window| matches!(window[0].as_str(), Some("-c" | "-lc" | "/c" | "-Command")))
.and_then(|window| window[1].as_str())
.filter(|command| !command.is_empty())
}
fn files_from_changes(changes: Option<&Value>) -> Vec<String> {
let Some(changes) = changes else {
return Vec::new();
};
match changes {
Value::Array(items) => items
.iter()
.filter_map(|item| {
item.get("path")
.or_else(|| item.get("file"))
.or_else(|| item.get("name"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.collect(),
Value::Object(map) => map.keys().cloned().collect(),
_ => Vec::new(),
}
}
}
pub mod claude {
use super::*;
#[derive(Clone, Debug, Default)]
pub struct ClaudeState {
pub session_id: Option<String>,
pub cwd: Option<String>,
pub project: Option<String>,
pub model: Option<String>,
pub transcript_path: Option<String>,
pub last_tool: Option<String>,
pub last_command: Option<String>,
pub last_files: Vec<String>,
}
pub fn normalize_stream_json(value: Value) -> Result<AgentEvent, AdapterError> {
let mut state = ClaudeState::default();
if let Some(event) = normalize_stream_value(value.clone(), &mut state, None) {
return Ok(event);
}
let mut event = normalize_value(value, SourceKind::Claude)?;
event.adapter = "claude.stream-json".to_string();
event.agent = "claude".to_string();
Ok(event)
}
pub fn normalize_stream(
input: &str,
path: Option<&Path>,
) -> Result<Vec<AgentEvent>, AdapterError> {
let mut state = ClaudeState::default();
normalize_stream_with_state(input, path, &mut state)
}
pub fn normalize_stream_with_state(
input: &str,
path: Option<&Path>,
state: &mut ClaudeState,
) -> Result<Vec<AgentEvent>, AdapterError> {
let mut events = Vec::new();
for line in input.lines().map(str::trim).filter(|line| !line.is_empty()) {
let value = serde_json::from_str::<Value>(line)?;
if let Some(event) = normalize_stream_value(value, state, path) {
events.push(event);
}
}
Ok(events)
}
pub fn normalize_stream_value(
value: Value,
state: &mut ClaudeState,
path: Option<&Path>,
) -> Option<AgentEvent> {
update_state_from_value(state, &value, path);
if let Some(event_name) = value.get("hook_event_name").and_then(Value::as_str) {
return hook_event(state, event_name, &value);
}
let message = value.get("message").unwrap_or(&value);
let message_type = value
.get("type")
.or_else(|| message.get("type"))
.and_then(Value::as_str)
.unwrap_or_default();
let subtype = value
.get("subtype")
.or_else(|| message.get("subtype"))
.and_then(Value::as_str)
.unwrap_or_default();
match message_type {
"system" if subtype == "init" || subtype.is_empty() => Some(build_event(
state,
timestamp_from(&value),
ClaudeEvent::new(
EventKind::SessionStart,
"claude session started",
62,
Severity::Notice,
)
.summary("stream capture found a claude session."),
)),
"assistant" => assistant_event(state, timestamp_from(&value), message),
"result" => result_event(state, timestamp_from(&value), &value),
"tool_result" => tool_result_event(state, timestamp_from(&value), &value),
"error" => Some(build_event(
state,
timestamp_from(&value),
ClaudeEvent::new(
EventKind::Error,
"claude stream error",
90,
Severity::Critical,
)
.summary("claude stream reported an error. raw output omitted."),
)),
"user" => None,
_ => None,
}
}
fn update_state_from_value(state: &mut ClaudeState, value: &Value, path: Option<&Path>) {
if let Some(session_id) = value
.get("session_id")
.or_else(|| {
value
.get("message")
.and_then(|message| message.get("session_id"))
})
.and_then(Value::as_str)
{
state.session_id = Some(session_id.to_string());
} else if state.session_id.is_none() {
state.session_id = session_id_from_path(path);
}
if let Some(cwd) = value.get("cwd").and_then(Value::as_str) {
state.cwd = Some(cwd.to_string());
state.project = project_from_cwd(cwd);
}
if let Some(model) = value
.get("model")
.or_else(|| {
value
.get("message")
.and_then(|message| message.get("model"))
})
.and_then(Value::as_str)
{
state.model = Some(model.to_string());
}
if let Some(transcript_path) = value.get("transcript_path").and_then(Value::as_str) {
state.transcript_path = Some(transcript_path.to_string());
}
}
fn hook_event(state: &ClaudeState, event_name: &str, value: &Value) -> Option<AgentEvent> {
let tool_name = value
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or("tool");
let denied = value
.get("permission_decision")
.or_else(|| value.get("decision"))
.and_then(Value::as_str)
.is_some_and(|decision| matches!(decision, "deny" | "denied" | "block" | "blocked"));
match event_name {
"SessionStart" => Some(build_event(
state,
None,
ClaudeEvent::new(
EventKind::SessionStart,
"claude session started",
62,
Severity::Notice,
)
.summary("hook captured a claude session start."),
)),
"PreToolUse" if denied => Some(build_event(
state,
None,
ClaudeEvent::new(
EventKind::PermissionDenied,
format!("claude denied {tool_name}"),
95,
Severity::Critical,
)
.summary("tool permission was denied. raw input omitted.")
.tool(tool_name)
.optional_command(command_from_tool_input(value.get("tool_input"))),
)),
"PreToolUse" => Some(build_event(
state,
None,
ClaudeEvent::new(
EventKind::PermissionRequest,
format!("claude requested {tool_name}"),
82,
Severity::Warning,
)
.summary("tool permission request captured without raw output.")
.tool(tool_name)
.optional_command(command_from_tool_input(value.get("tool_input"))),
)),
"PostToolUse" => {
let failed = tool_response_failed(value.get("tool_response"));
let files = files_from_tool_input(value.get("tool_input"));
let command = command_from_tool_input(value.get("tool_input"));
let test_command = command.as_deref().is_some_and(is_test_command);
Some(build_event(
state,
None,
ClaudeEvent::new(
if test_command && failed {
EventKind::TestFail
} else if test_command {
EventKind::TestPass
} else if failed {
EventKind::ToolFail
} else if is_file_tool(tool_name) {
EventKind::FileChanged
} else {
EventKind::ToolComplete
},
if test_command && failed {
"claude tests failed".to_string()
} else if test_command {
"claude tests passed".to_string()
} else if failed {
format!("claude {tool_name} failed")
} else if is_file_tool(tool_name) {
"claude changed files".to_string()
} else {
format!("claude {tool_name} completed")
},
if test_command && failed {
90
} else if test_command {
76
} else if failed {
86
} else {
58
},
if failed {
Severity::Warning
} else if test_command {
Severity::Notice
} else {
Severity::Info
},
)
.summary(if test_command && failed {
"test command failed."
} else if test_command {
"test command passed."
} else {
"tool lifecycle captured without raw output."
})
.tool(tool_name)
.optional_command(command)
.files(files),
))
}
"Stop" | "SubagentStop" => Some(build_event(
state,
None,
ClaudeEvent::new(
EventKind::TurnComplete,
if event_name == "SubagentStop" {
"claude subagent completed"
} else {
"claude turn completed"
},
78,
Severity::Notice,
)
.summary("claude lifecycle completed. raw transcript omitted."),
)),
"PreCompact" => Some(build_event(
state,
None,
ClaudeEvent::new(
EventKind::SummaryCreated,
"claude compacted context",
64,
Severity::Info,
)
.summary("context compaction captured without raw transcript."),
)),
"Notification" => Some(build_event(
state,
None,
ClaudeEvent::new(
EventKind::AgentMessage,
"claude notification received",
30,
Severity::Info,
)
.summary("notification captured without raw content."),
)),
_ => None,
}
}
fn assistant_event(
state: &mut ClaudeState,
timestamp: Option<&str>,
message: &Value,
) -> Option<AgentEvent> {
let content = message.get("content").and_then(Value::as_array);
let tool_use = content.and_then(|items| {
items.iter().find(|item| {
item.get("type").and_then(Value::as_str) == Some("tool_use")
|| item.get("type").and_then(Value::as_str) == Some("server_tool_use")
})
});
if let Some(tool_use) = tool_use {
let name = tool_use
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool");
let input = tool_use.get("input");
let command = command_from_tool_input(input);
let files = files_from_tool_input(input);
state.last_tool = Some(name.to_string());
state.last_command = command.clone();
state.last_files = files.clone();
return Some(build_event(
state,
timestamp,
ClaudeEvent::new(
if name == "Bash" {
EventKind::CommandExec
} else {
EventKind::ToolStart
},
if name == "Bash" {
"claude started a command".to_string()
} else {
format!("claude started {name}")
},
if name == "Bash" { 46 } else { 34 },
Severity::Info,
)
.summary("tool call captured without raw output.")
.tool(name)
.optional_command(command)
.files(files),
));
}
let summary = message
.get("content")
.and_then(display_safe_content_sentence)
.unwrap_or_else(|| "assistant message recorded without raw content.".to_string());
Some(build_event(
state,
timestamp,
ClaudeEvent::new(
EventKind::AgentMessage,
"claude posted an update",
36,
Severity::Info,
)
.summary(summary),
))
}
fn result_event(
state: &ClaudeState,
timestamp: Option<&str>,
value: &Value,
) -> Option<AgentEvent> {
let failed = value
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or_else(|| value.get("subtype").and_then(Value::as_str) == Some("error"));
let duration = value
.get("duration_ms")
.and_then(Value::as_u64)
.map(|duration| format!("{duration}ms"));
let summary = value
.get("result")
.and_then(|value| match value {
Value::String(value) => display_safe_completion_summary(value),
value => display_safe_content_sentence(value),
})
.or_else(|| {
duration
.as_deref()
.map(|duration| format!("duration {duration}. raw content omitted."))
})
.unwrap_or_else(|| "result captured without raw content.".to_string());
Some(build_event(
state,
timestamp,
ClaudeEvent::new(
if failed {
EventKind::TurnFail
} else {
EventKind::TurnComplete
},
if failed {
"claude turn failed"
} else {
"claude turn completed"
},
if failed { 90 } else { 80 },
if failed {
Severity::Warning
} else {
Severity::Notice
},
)
.summary(summary),
))
}
fn tool_result_event(
state: &ClaudeState,
timestamp: Option<&str>,
value: &Value,
) -> Option<AgentEvent> {
let failed = value
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false);
let command = state.last_command.clone();
let tool = state
.last_tool
.clone()
.unwrap_or_else(|| "tool".to_string());
let files = state.last_files.clone();
let test_command = command.as_deref().is_some_and(is_test_command);
Some(build_event(
state,
timestamp,
ClaudeEvent::new(
if test_command && failed {
EventKind::TestFail
} else if test_command {
EventKind::TestPass
} else if failed {
EventKind::ToolFail
} else {
EventKind::ToolComplete
},
if test_command && failed {
"claude tests failed"
} else if test_command {
"claude tests passed"
} else if failed {
"claude tool failed"
} else {
"claude tool completed"
},
if test_command && failed {
90
} else if test_command {
76
} else if failed {
84
} else {
48
},
if failed {
Severity::Warning
} else if test_command {
Severity::Notice
} else {
Severity::Info
},
)
.summary(if test_command && failed {
"test command failed."
} else if test_command {
"test command passed."
} else {
"tool result captured without raw output."
})
.tool(tool)
.optional_command(command)
.files(files),
))
}
#[derive(Clone, Debug)]
struct ClaudeEvent {
kind: EventKind,
title: String,
summary: Option<String>,
tool: Option<String>,
command: Option<String>,
files: Vec<String>,
score_hint: u8,
severity: Severity,
}
impl ClaudeEvent {
fn new(
kind: EventKind,
title: impl Into<String>,
score_hint: u8,
severity: Severity,
) -> Self {
Self {
kind,
title: title.into(),
summary: None,
tool: None,
command: None,
files: Vec::new(),
score_hint,
severity,
}
}
fn summary(mut self, summary: impl Into<String>) -> Self {
self.summary = Some(summary.into());
self
}
fn tool(mut self, tool: impl Into<String>) -> Self {
self.tool = Some(tool.into());
self
}
fn optional_command(mut self, command: Option<String>) -> Self {
self.command = command;
self
}
fn files(mut self, files: Vec<String>) -> Self {
self.files = files;
self
}
}
fn build_event(state: &ClaudeState, timestamp: Option<&str>, draft: ClaudeEvent) -> AgentEvent {
let mut event = AgentEvent::new(SourceKind::Claude, draft.kind, draft.title);
event.agent = "claude".to_string();
event.adapter = "claude.stream-json".to_string();
event.session_id = state.session_id.clone();
event.project = state.project.clone();
event.cwd = state.cwd.clone();
event.occurred_at = timestamp.and_then(parse_timestamp);
event.summary = draft.summary;
event.tool = draft.tool;
event.command = draft.command;
event.files = draft.files;
event.tags = vec!["claude".to_string(), "stream-json".to_string()];
event.score_hint = Some(draft.score_hint);
event.severity = draft.severity;
event
}
fn timestamp_from(value: &Value) -> Option<&str> {
value
.get("timestamp")
.or_else(|| value.get("created_at"))
.and_then(Value::as_str)
}
fn parse_timestamp(value: &str) -> Option<OffsetDateTime> {
OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).ok()
}
fn project_from_cwd(cwd: &str) -> Option<String> {
project_label_from_cwd(cwd)
}
fn session_id_from_path(path: Option<&Path>) -> Option<String> {
path?
.file_stem()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned)
}
fn command_from_tool_input(input: Option<&Value>) -> Option<String> {
let input = input?;
input
.get("command")
.or_else(|| input.get("cmd"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
fn files_from_tool_input(input: Option<&Value>) -> Vec<String> {
let Some(input) = input else {
return Vec::new();
};
["file_path", "path", "notebook_path"]
.into_iter()
.filter_map(|key| {
input
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.collect()
}
fn tool_response_failed(response: Option<&Value>) -> bool {
response.is_some_and(|response| {
response
.get("is_error")
.or_else(|| response.get("error"))
.and_then(Value::as_bool)
.unwrap_or(false)
|| response.get("status").and_then(Value::as_str) == Some("error")
})
}
fn is_file_tool(tool: &str) -> bool {
matches!(tool, "Write" | "Edit" | "MultiEdit" | "NotebookEdit")
}
}
pub mod mcp {
use super::*;
pub fn normalize_json_rpc(value: Value) -> Result<AgentEvent, AdapterError> {
let mut event = normalize_value(value, SourceKind::Mcp)?;
event.adapter = "mcp.json-rpc".to_string();
Ok(event)
}
}
#[cfg(test)]
mod tests {
use super::claude::normalize_stream;
use super::codex::normalize_transcript;
use agent_feed_core::EventKind;
#[test]
fn codex_transcript_normalizes_display_safe_events() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"019dbd7d-4f56-7a11-9d9d-038a73a694af","cwd":"/home/mosure/repos/burn_dragon"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos/burn_dragon","model":"gpt-5.5","turn_id":"turn_1"}}
{"type":"event_msg","timestamp":"2026-04-24T03:16:50.000Z","payload":{"type":"task_started","turn_id":"turn_1"}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"exec_command_end","status":"completed","exit_code":0,"duration":"120ms","command":["cargo","test"],"stdout":"secret output"}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:02.000Z","payload":{"type":"patch_apply_end","success":true,"changes":{"src/lib.rs":{}}}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:05.000Z","payload":{"type":"task_complete","turn_id":"turn_1","last_agent_message":"Implemented the release flow.\n\nSecret token output omitted.","duration_ms":15000}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events.len(), 5);
assert_eq!(events[0].kind, EventKind::SessionStart);
assert_eq!(
events[0].session_id.as_deref(),
Some("019dbd7d-4f56-7a11-9d9d-038a73a694af")
);
assert_eq!(events[1].kind, EventKind::TurnStart);
assert_eq!(events[2].kind, EventKind::TestPass);
assert_eq!(events[2].command.as_deref(), Some("cargo test"));
assert!(
!events[2]
.summary
.as_deref()
.unwrap_or_default()
.contains("secret output")
);
assert_eq!(events[3].kind, EventKind::FileChanged);
assert_eq!(events[3].files, vec!["src/lib.rs"]);
assert_eq!(events[4].kind, EventKind::TurnComplete);
assert_eq!(events[4].title, "codex turn completed");
assert_eq!(
events[4].summary.as_deref(),
Some("Implemented the release flow.")
);
assert!(
!events[4]
.summary
.as_deref()
.unwrap_or_default()
.contains("Secret")
);
}
#[test]
fn codex_transcript_normalizes_plan_and_aborted_turn_events() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos/agent_feed"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos/agent_feed","turn_id":"turn_1"}}
{"type":"event_msg","timestamp":"2026-04-24T03:16:50.000Z","payload":{"type":"item_completed","turn_id":"turn_1","item":{"type":"Plan","text":"raw plan text"}}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:05.000Z","payload":{"type":"turn_aborted","turn_id":"turn_1","reason":"interrupted by operator"}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events.len(), 3);
assert_eq!(events[1].kind, EventKind::PlanUpdate);
assert_eq!(
events[1].summary.as_deref(),
Some("plan update recorded without raw plan text.")
);
assert_eq!(events[2].kind, EventKind::TurnFail);
assert_eq!(
events[2].summary.as_deref(),
Some("interrupted by operator.")
);
}
#[test]
fn codex_transcript_normalizes_legacy_agent_reel_workspace_name() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos/agent_reel"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos/agent_reel","turn_id":"turn_1"}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events.len(), 1);
assert_eq!(events[0].project.as_deref(), Some("agent_feed"));
}
#[test]
fn codex_transcript_extracts_display_safe_agent_message_text() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos/agent_feed"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos/agent_feed","turn_id":"turn_1"}}
{"type":"response_item","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Browser feed auth now returns to the CLI callback.\n\nsecret token hidden"}]}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events.len(), 2);
assert_eq!(events[1].kind, EventKind::AgentMessage);
assert_eq!(
events[1].summary.as_deref(),
Some("Browser feed auth now returns to the CLI callback.")
);
}
#[test]
fn codex_transcript_deduplicates_paired_agent_messages() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos/burn_p2p"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos/burn_p2p","turn_id":"turn_1"}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"agent_message","message":{"role":"assistant","content":[{"type":"output_text","text":"Burn_p2p browser training receipts now flush reliably."}]}}}
{"type":"response_item","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Burn_p2p browser training receipts now flush reliably."}]}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
let messages = events
.iter()
.filter(|event| event.kind == EventKind::AgentMessage)
.collect::<Vec<_>>();
assert_eq!(messages.len(), 1);
assert_eq!(
messages[0].summary.as_deref(),
Some("Burn_p2p browser training receipts now flush reliably.")
);
}
#[test]
fn codex_final_answer_agent_message_is_turn_complete() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-05-06T16:15:00.000Z","payload":{"id":"session","cwd":"/home/mosure/repos/burn_dragon"}}
{"type":"turn_context","timestamp":"2026-05-06T16:15:01.000Z","payload":{"cwd":"/home/mosure/repos/burn_dragon","turn_id":"turn_1"}}
{"type":"event_msg","timestamp":"2026-05-06T16:28:00.000Z","payload":{"type":"agent_message","phase":"final_answer","message":"Burn_dragon deploy checks now verify the browser seed handoff before release."}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
let complete = events
.iter()
.find(|event| event.kind == EventKind::TurnComplete)
.expect("final answer promotes to completion");
assert_eq!(complete.title, "codex turn completed");
assert_eq!(complete.project.as_deref(), Some("burn_dragon"));
assert_eq!(complete.score_hint, Some(86));
assert_eq!(
complete.summary.as_deref(),
Some("Burn_dragon deploy checks now verify the browser seed handoff before release.")
);
}
#[test]
fn codex_final_answer_response_item_is_turn_complete_once() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-05-06T16:15:00.000Z","payload":{"id":"session","cwd":"/home/mosure/repos/burn_p2p"}}
{"type":"turn_context","timestamp":"2026-05-06T16:15:01.000Z","payload":{"cwd":"/home/mosure/repos/burn_p2p","turn_id":"turn_1"}}
{"type":"event_msg","timestamp":"2026-05-06T16:27:59.000Z","payload":{"type":"agent_message","phase":"final_answer","message":"Burn_p2p bootstrap discovery now keeps provider lookup coverage stable."}}
{"type":"response_item","timestamp":"2026-05-06T16:28:00.000Z","payload":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"Burn_p2p bootstrap discovery now keeps provider lookup coverage stable."}]}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
let completions = events
.iter()
.filter(|event| event.kind == EventKind::TurnComplete)
.collect::<Vec<_>>();
assert_eq!(completions.len(), 1);
assert_eq!(
completions[0].summary.as_deref(),
Some("Burn_p2p bootstrap discovery now keeps provider lookup coverage stable.")
);
assert!(
!events
.iter()
.any(|event| event.kind == EventKind::AgentMessage)
);
}
#[test]
fn codex_task_complete_extracts_result_from_generic_final_answer() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos","turn_id":"turn_1"}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"task_complete","turn_id":"turn_1","last_agent_message":"Implemented and pushed.\n\nIn `burn_dragon`:\n- Added a single Rust xtask broker: `xtask agent-task ...`\n- Moved the former large `scripts/agent_task.py` / `summarize_github_run.py` behavior into xtask.\n- Added [agent_task.py](/home/mosure/repos/burn_dragon/scripts/agent_task.py) compatibility checks.\n\nValidation passed:\n- `cargo check --manifest-path Cargo.toml -p xtask`\n\nPushed commits:\n- `burn_dragon`: `8572550 chore: consolidate agent tooling in xtask`","duration_ms":15000}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
let complete = events
.iter()
.find(|event| event.kind == EventKind::TurnComplete)
.expect("turn completion event");
let summary = complete.summary.as_deref().expect("summary present");
assert_eq!(complete.project.as_deref(), Some("burn_dragon"));
assert!(summary.starts_with("burn_dragon added a single Rust xtask broker"));
assert!(summary.contains("xtask"));
assert!(!summary.contains("Implemented and pushed"));
assert!(!summary.contains("/home/"));
assert!(!summary.contains("Validation passed"));
assert!(!summary.contains("Pushed commits"));
}
#[test]
fn codex_transcript_infers_project_from_root_workspace_message() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos","turn_id":"turn_1"}}
{"type":"response_item","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Burn_dragon browser training now uses the shared p2p receipt path."}]}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:05.000Z","payload":{"type":"task_complete","turn_id":"turn_1","last_agent_message":"Burn_p2p publish receipts are now project aware.","duration_ms":5000}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events[0].project.as_deref(), Some("repos"));
assert_eq!(events[1].kind, EventKind::AgentMessage);
assert_eq!(events[1].project.as_deref(), Some("burn_dragon"));
assert_eq!(events[2].kind, EventKind::TurnComplete);
assert_eq!(events[2].project.as_deref(), Some("burn_p2p"));
}
#[test]
fn codex_transcript_does_not_infer_tool_names_as_projects() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos","turn_id":"turn_1"}}
{"type":"response_item","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"function_call","name":"write_stdin","call_id":"call_1","arguments":{"session_id":123,"chars":"","yield_time_ms":1000}}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:05.000Z","payload":{"type":"task_complete","turn_id":"turn_1","last_agent_message":"waiting for the build to finish.","duration_ms":5000}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events[0].project.as_deref(), Some("repos"));
assert_eq!(events[1].kind, EventKind::ToolStart);
assert_eq!(events[1].project.as_deref(), Some("repos"));
assert_eq!(events[2].kind, EventKind::TurnComplete);
assert_eq!(events[2].project.as_deref(), Some("repos"));
}
#[test]
fn codex_transcript_uses_tool_workdir_for_project_tags() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos","turn_id":"turn_1"}}
{"type":"response_item","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":{"cmd":"cargo test -p burn_p2p_browser","workdir":"/home/mosure/repos/burn_p2p"}}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:04.000Z","payload":{"type":"exec_command_end","call_id":"call_1","turn_id":"turn_1","status":"completed","exit_code":0,"duration":"4s","command":["cargo","test","-p","burn_p2p_browser"],"cwd":"/home/mosure/repos/burn_p2p"}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:07.000Z","payload":{"type":"task_complete","turn_id":"turn_1","last_agent_message":"Burn_p2p browser training receipts now flush reliably.","duration_ms":7000}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events[0].project.as_deref(), Some("repos"));
assert_eq!(events[1].kind, EventKind::CommandExec);
assert_eq!(events[1].project.as_deref(), Some("burn_p2p"));
assert_eq!(
events[1].cwd.as_deref(),
Some("/home/mosure/repos/burn_p2p")
);
assert_eq!(events[2].kind, EventKind::TestPass);
assert_eq!(events[2].project.as_deref(), Some("burn_p2p"));
assert_eq!(
events[2].cwd.as_deref(),
Some("/home/mosure/repos/burn_p2p")
);
assert_eq!(events[3].kind, EventKind::TurnComplete);
assert_eq!(events[3].project.as_deref(), Some("burn_p2p"));
assert_eq!(
events[3].cwd.as_deref(),
Some("/home/mosure/repos/burn_p2p")
);
}
#[test]
fn codex_root_workspace_message_prefers_mentioned_project_over_last_tool_workdir() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos","turn_id":"turn_1"}}
{"type":"response_item","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":{"cmd":"gh run list --repo aberration-technology/burn_dragon","workdir":"/home/mosure/repos/burn_dragon"}}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:04.000Z","payload":{"type":"exec_command_end","call_id":"call_1","turn_id":"turn_1","status":"completed","exit_code":0,"duration":"4s","command":["gh","run","list"],"cwd":"/home/mosure/repos/burn_dragon"}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:07.000Z","payload":{"type":"agent_message","message":"`burn_p2p` Release Readiness is green too. Only `burn_p2p` PR Fast and the rerun `burn_dragon` CI remain active."}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
let message = events
.iter()
.find(|event| event.kind == EventKind::AgentMessage)
.expect("agent message event");
assert_eq!(message.project.as_deref(), Some("burn_p2p"));
let summary = message.summary.as_deref().expect("summary present");
assert!(summary.starts_with("burn_p2p` Release Readiness is green"));
assert!(summary.contains("burn_dragon"));
}
#[test]
fn codex_root_workspace_polling_keeps_last_concrete_project_context() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos","turn_id":"turn_1"}}
{"type":"response_item","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":{"cmd":"gh run view 25086337252 --repo aberration-technology/burn_dragon","workdir":"/home/mosure/repos/burn_dragon"}}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:04.000Z","payload":{"type":"exec_command_end","call_id":"call_1","turn_id":"turn_1","status":"completed","exit_code":0,"duration":"4s","command":["gh","run","view"],"cwd":"/home/mosure/repos/burn_dragon"}}
{"type":"response_item","timestamp":"2026-04-24T03:17:08.000Z","payload":{"type":"function_call","name":"exec_command","call_id":"call_2","arguments":{"cmd":"sleep 300","workdir":"/home/mosure/repos"}}}
{"type":"event_msg","timestamp":"2026-04-24T03:22:08.000Z","payload":{"type":"exec_command_end","call_id":"call_2","turn_id":"turn_1","status":"completed","exit_code":0,"duration":"300s","command":["sleep","300"],"cwd":"/home/mosure/repos"}}
{"type":"event_msg","timestamp":"2026-04-24T03:22:12.000Z","payload":{"type":"agent_message","message":"The deploy has moved past binary builds and is now in terraform apply."}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
let message = events
.iter()
.find(|event| {
event.kind == EventKind::AgentMessage
&& event
.summary
.as_deref()
.is_some_and(|summary| summary.contains("terraform apply"))
})
.expect("agent message event");
assert_eq!(message.project.as_deref(), Some("burn_dragon"));
assert_eq!(
message.cwd.as_deref(),
Some("/home/mosure/repos/burn_dragon")
);
}
#[test]
fn codex_transcript_ignores_user_and_developer_messages() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos/agent_feed"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos/agent_feed","turn_id":"turn_1"}}
{"type":"response_item","timestamp":"2026-04-24T03:16:50.000Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"do i need to restart codex sessions after launching agent feed?"}]}}
{"type":"response_item","timestamp":"2026-04-24T03:16:51.000Z","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"internal instruction"}]}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:05.000Z","payload":{"type":"task_complete","turn_id":"turn_1","last_agent_message":"Feed watches future transcript writes without restarting sessions.","duration_ms":15000}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events.len(), 2);
assert_eq!(events[1].kind, EventKind::TurnComplete);
assert_eq!(
events[1].summary.as_deref(),
Some("Feed watches future transcript writes without restarting sessions.")
);
}
#[test]
fn codex_transcript_rejects_low_signal_processor_json_as_agent_copy() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos/agent_feed"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos/agent_feed","turn_id":"turn_1"}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"task_complete","turn_id":"turn_1","last_agent_message":"{\"headline\":\"codex changes two files, matches prior edit story\",\"deck\":\"two changed files repeat the recent file-change summary.\",\"publish\":false,\"memory_digest\":\"repeat\"}","duration_ms":15000}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events.len(), 2);
assert_eq!(events[1].kind, EventKind::TurnComplete);
assert!(
!events[1]
.summary
.as_deref()
.unwrap_or_default()
.contains("\"headline\"")
);
assert_eq!(events[1].summary.as_deref(), Some("turn completed in 15s."));
}
#[test]
fn codex_transcript_extracts_mcp_context_as_rollup_signal() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos/agent_feed"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos/agent_feed","turn_id":"turn_1"}}
{"type":"response_item","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"custom_tool_call","name":"mcp__codex_apps__github__fetch_pr","arguments":"{}"}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events.len(), 2);
assert_eq!(events[1].kind, EventKind::McpCall);
assert_eq!(events[1].tool.as_deref(), Some("github"));
assert_eq!(events[1].command.as_deref(), Some("mcp github context"));
assert_eq!(
events[1].summary.as_deref(),
Some("github collaboration context captured through mcp.")
);
}
#[test]
fn codex_transcript_prefers_parsed_command_over_shell_wrapper() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos/agent_feed"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos/agent_feed","turn_id":"turn_1"}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"exec_command_end","status":"failed","exit_code":1,"command":["/usr/bin/zsh","-lc","cargo test --all"],"parsed_cmd":[{"type":"unknown","cmd":"cargo test --all"}]}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events[1].kind, EventKind::TestFail);
assert_eq!(events[1].command.as_deref(), Some("cargo test --all"));
}
#[test]
fn codex_transcript_extracts_shell_inner_command_when_parsed_command_missing() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos/agent_feed"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos/agent_feed","turn_id":"turn_1"}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"exec_command_end","status":"failed","exit_code":1,"command":["/usr/bin/zsh","-lc","git status --short"]}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events[1].kind, EventKind::ToolFail);
assert_eq!(events[1].command.as_deref(), Some("git status --short"));
}
#[test]
fn codex_transcript_keeps_plain_wrapper_when_no_inner_command_exists() {
let transcript = r#"
{"type":"session_meta","timestamp":"2026-04-24T03:16:49.696Z","payload":{"id":"session","cwd":"/home/mosure/repos/agent_feed"}}
{"type":"turn_context","timestamp":"2026-04-24T03:16:49.697Z","payload":{"cwd":"/home/mosure/repos/agent_feed","turn_id":"turn_1"}}
{"type":"event_msg","timestamp":"2026-04-24T03:17:00.000Z","payload":{"type":"exec_command_end","status":"failed","exit_code":1,"command":["/usr/bin/zsh"]}}
"#;
let events = normalize_transcript(transcript, None).expect("transcript normalizes");
assert_eq!(events[1].kind, EventKind::ToolFail);
assert_eq!(events[1].command.as_deref(), Some("/usr/bin/zsh"));
}
#[test]
fn claude_stream_json_normalizes_display_safe_events() {
let stream = r#"
{"type":"system","subtype":"init","session_id":"claude-1","cwd":"/home/mosure/repos/agent_feed","model":"claude-sonnet-4-6"}
{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo test","raw_secret":"hidden"}}]}}
{"type":"result","subtype":"success","duration_ms":1200,"result":"raw answer omitted"}
"#;
let events = normalize_stream(stream, None).expect("stream normalizes");
assert_eq!(events.len(), 3);
assert_eq!(events[0].kind, EventKind::SessionStart);
assert_eq!(events[0].session_id.as_deref(), Some("claude-1"));
assert_eq!(events[1].kind, EventKind::CommandExec);
assert_eq!(events[1].command.as_deref(), Some("cargo test"));
assert!(
!events[1]
.summary
.as_deref()
.unwrap_or_default()
.contains("hidden")
);
assert_eq!(events[2].kind, EventKind::TurnComplete);
}
#[test]
fn claude_tool_result_uses_prior_bash_context_for_test_signal() {
let stream = r#"
{"type":"system","subtype":"init","session_id":"claude-1","cwd":"/home/mosure/repos/agent_feed","model":"claude-sonnet-4-6"}
{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo test --all"}}]}}
{"type":"tool_result","is_error":true,"content":"raw failing output"}
"#;
let events = normalize_stream(stream, None).expect("stream normalizes");
assert_eq!(events.len(), 3);
assert_eq!(events[2].kind, EventKind::TestFail);
assert_eq!(events[2].title, "claude tests failed");
assert_eq!(events[2].command.as_deref(), Some("cargo test --all"));
assert!(
!events[2]
.summary
.as_deref()
.unwrap_or_default()
.contains("raw failing output")
);
}
#[test]
fn claude_result_extracts_display_safe_turn_summary() {
let stream = r#"
{"type":"system","subtype":"init","session_id":"claude-1","cwd":"/home/mosure/repos/agent_feed","model":"claude-sonnet-4-6"}
{"type":"result","subtype":"success","duration_ms":1200,"result":"Browser feed subscriptions now explain the public discovery path.\n\nstdout hidden"}
"#;
let events = normalize_stream(stream, None).expect("stream normalizes");
assert_eq!(events.len(), 2);
assert_eq!(events[1].kind, EventKind::TurnComplete);
assert_eq!(
events[1].summary.as_deref(),
Some("Browser feed subscriptions now explain the public discovery path.")
);
}
#[test]
fn claude_hook_json_normalizes_permission_events() {
let stream = r#"
{"hook_event_name":"SessionStart","session_id":"claude-2","cwd":"/home/mosure/repos/agent_feed","source":"startup","model":"claude-sonnet-4-6"}
{"hook_event_name":"PreToolUse","session_id":"claude-2","tool_name":"Bash","tool_input":{"command":"git push"}}
{"hook_event_name":"PostToolUse","session_id":"claude-2","tool_name":"Edit","tool_input":{"file_path":"src/lib.rs"},"tool_response":{"is_error":false}}
"#;
let events = normalize_stream(stream, None).expect("hooks normalize");
assert_eq!(events.len(), 3);
assert_eq!(events[1].kind, EventKind::PermissionRequest);
assert_eq!(events[1].tool.as_deref(), Some("Bash"));
assert_eq!(events[1].command.as_deref(), Some("git push"));
assert_eq!(events[2].kind, EventKind::FileChanged);
assert_eq!(events[2].files, vec!["src/lib.rs"]);
}
}