use super::filters::{escaped, linked};
use crate::harness::interpretation::event::Event;
use crate::logs::catalog::Kind;
use crate::otlp_jsonl::{AnyValue, LogRecord};
use serde_json::Value;
use std::collections::HashMap;
enum Part {
Message(String, String, Option<String>),
Thinking(String),
Tool(String, String),
CodexEdit(String, String),
Plain(String),
}
pub(crate) fn render(lines: &str, kind: Kind) -> String {
if matches!(kind, Kind::Command) {
return format!("<pre class=\"command-output\">{}</pre>", linked(lines));
}
let mut html = String::new();
let mut tools_open = false;
let mut conversation = Vec::new();
let mut edits = HashMap::new();
let mut messages = HashMap::new();
for part in crate::logs::session::records(lines).flat_map(record_parts) {
let snapshots = match &part {
Part::Message(_, _, Some(id)) => Some((&mut messages, id)),
Part::CodexEdit(id, _) => Some((&mut edits, id)),
_ => None,
};
if let Some((snapshots, id)) = snapshots {
if let Some(index) = snapshots.get(id) {
conversation[*index] = part;
continue;
}
snapshots.insert(id.clone(), conversation.len());
}
conversation.push(part);
}
for part in conversation {
if matches!(part, Part::Tool(..) | Part::CodexEdit(..)) {
if !tools_open {
html.push_str(
"<details class=\"tool-group\"><summary>Tools</summary><div class=\"tools\">",
);
tools_open = true;
}
} else if tools_open {
html.push_str("</div></details>");
tools_open = false;
}
match part {
Part::Message(role, text, id) => html.push_str(&format!(
"<article class=\"message\"{}><h3>{}</h3><pre>{}</pre></article>",
id.map(|id| format!(" data-message-id=\"{}\"", escaped(&id)))
.unwrap_or_default(),
escaped(&role),
linked(&text)
)),
Part::Thinking(text) => html.push_str(&format!(
"<details class=\"thinking\"><summary>Thinking</summary><pre>{}</pre></details>",
linked(&text)
)),
Part::Tool(name, body) => html.push_str(&format!(
"<article class=\"tool\" data-tool=\"{}\"><h3>{}</h3>{body}</article>",
escaped(&name),
escaped(&name)
)),
Part::CodexEdit(id, body) => html.push_str(&format!(
"<article class=\"tool\" data-tool=\"Edit\" data-codex-edit=\"{}\"><h3>Edit</h3>{body}</article>",
escaped(&id)
)),
Part::Plain(text) => html.push_str(&format!(
"<pre class=\"unknown-event\">{}</pre>",
linked(&text)
)),
}
}
if tools_open {
html.push_str("</div></details>");
}
html
}
fn record_parts(record: LogRecord) -> Vec<Part> {
let Some(AnyValue::String(raw)) = record.body else {
return Vec::new();
};
if record
.attributes
.iter()
.any(|attr| attr.key == "thirdshift.migration.original")
{
return parts(&raw);
}
let Some(attribute) = record
.attributes
.iter()
.find(|attr| attr.key == "thirdshift.events")
else {
return parts(&raw);
};
let Some(AnyValue::String(events)) = &attribute.value else {
return vec![Part::Plain(raw)];
};
let Ok(Value::Array(events)) = serde_json::from_str(events) else {
return vec![Part::Plain(raw)];
};
let envelope = serde_json::from_str::<Value>(&raw).unwrap_or_default();
let thinking = matches!(
envelope["type"].as_str(),
Some("item.started" | "item.updated" | "item.completed")
) && envelope["item"]["type"] == "reasoning";
let mut output = Vec::new();
for event in events {
match serde_json::from_value::<Event>(event) {
Ok(Event::Message {
id,
parent_id,
role,
text,
}) => {
output.push(if thinking {
Part::Thinking(text)
} else {
Part::Message(
role,
text,
id.map(|id| serde_json::to_string(&(parent_id, id)).unwrap()),
)
});
}
Ok(Event::ToolCallStarted { id, tool: name, .. })
if name == "apply_patch" && envelope["item"]["type"] == "file_change" =>
{
output.push(codex_edit(&id, &envelope["item"]));
}
Ok(Event::ToolCallStarted {
tool: name, input, ..
}) => {
output.push(tool(&name, &input));
}
Ok(Event::ToolCallEnded {
id,
tool: name,
result,
..
}) if name == "apply_patch"
&& (result["source"] == "codex_session_file"
|| envelope["item"]["type"] == "file_change") =>
{
let record = if result["source"] == "codex_session_file" {
&result
} else {
&envelope["item"]
};
output.push(codex_edit(&id, record));
}
Ok(Event::ToolCallEnded {
tool: name,
input,
result,
..
}) => {
let mut part = tool(&name, &input);
if let Part::Tool(_, body) = &mut part {
body.push_str(&pre("result", &pretty(&result)));
}
output.push(part);
}
Ok(_) => {}
Err(_) => output.push(Part::Plain(raw.clone())),
}
}
if output.is_empty() {
parts(&raw)
} else {
if matches!(envelope["type"].as_str(), Some("assistant" | "user")) {
output.extend(
parts(&raw)
.into_iter()
.filter(|part| matches!(part, Part::Thinking(_) | Part::Plain(_))),
);
}
output
}
}
fn parts(line: &str) -> Vec<Part> {
let Ok(event) = serde_json::from_str::<Value>(line) else {
return vec![Part::Plain(line.into())];
};
match event["type"].as_str() {
Some("tool_call_ended")
if event["tool"] == "apply_patch"
&& event["result"]["source"] == "codex_session_file" =>
{
vec![codex_edit(text(&event, "id"), &event["result"])]
}
Some("assistant" | "user") => {
let role = text(&event, "type");
match &event["message"]["content"] {
Value::String(content) => vec![Part::Message(role.into(), content.clone(), None)],
Value::Array(blocks) if !blocks.is_empty() => blocks
.iter()
.map(|block| match block["type"].as_str() {
Some("text") => {
Part::Message(role.into(), text(block, "text").into(), None)
}
Some("thinking") => Part::Thinking(text(block, "thinking").into()),
Some("tool_use") => tool(text(block, "name"), &block["input"]),
Some("tool_result") => Part::Tool(
"Tool result".into(),
pre("result", &pretty(&block["content"])),
),
_ => Part::Plain(pretty(block)),
})
.collect(),
_ => vec![Part::Plain(line.into())],
}
}
Some("item.started" | "item.updated" | "item.completed") => {
let item = &event["item"];
vec![match item["type"].as_str() {
Some("agent_message") => {
Part::Message("assistant".into(), text(item, "text").into(), None)
}
Some("reasoning") => Part::Thinking(text(item, "text").into()),
Some("command_execution") => tool("Bash", item),
Some("file_change") => codex_edit(text(item, "id"), item),
Some("mcp_tool_call") => tool(text(item, "tool"), item),
Some("web_search") => tool("Grep", item),
_ => Part::Plain(line.into()),
}]
}
_ => vec![Part::Plain(line.into())],
}
}
fn codex_edit(id: &str, record: &Value) -> Part {
let body = if record["source"] == "codex_session_file" {
let diffs: String = record["changes"]
.as_array()
.into_iter()
.flatten()
.map(|change| {
format!(
"{}{}",
pre("path", text(change, "path")),
pre("diff", text(change, "diff"))
)
})
.collect();
format!("<p>Codex’s record of this edit</p>{diffs}")
} else {
format!(
"<p>Patch unavailable in this log.</p>{}",
pre("fallback", &pretty(record))
)
};
Part::CodexEdit(id.into(), body)
}
fn tool(name: &str, input: &Value) -> Part {
let arguments = input.get("arguments").unwrap_or(input);
let body = match name.to_ascii_lowercase().as_str() {
"bash" | "exec_command" => format!(
"{}{}",
pre("bash", text(input, "command")),
pre("result", &pretty(input))
),
"edit" | "apply_patch" => {
if input["changes"].is_array() {
return Part::Tool(
name.into(),
format!(
"<p>Patch unavailable in this log.</p>{}",
pre("fallback", &pretty(input))
),
);
}
let diff = if arguments["old_string"].is_string() && arguments["new_string"].is_string()
{
let path = text(arguments, "file_path");
let old = text(arguments, "old_string");
let new = text(arguments, "new_string");
format!(
"--- a/{path}\n+++ b/{path}\n@@ -1,{} +1,{} @@\n{}{}",
old.lines().count(),
new.lines().count(),
old.lines()
.map(|line| format!("-{line}\n"))
.collect::<String>(),
new.lines()
.map(|line| format!("+{line}\n"))
.collect::<String>()
)
} else {
return Part::Tool(name.into(), pre("fallback", &pretty(input)));
};
pre("diff", &diff)
}
"read" | "read_file" => format!(
"{}{}",
pre("read", text(arguments, "file_path")),
pre("result", &pretty(input))
),
"grep" | "search" => format!(
"{}{}",
pre("grep", text(arguments, "pattern")),
pre("result", &pretty(input))
),
_ => pre("fallback", &pretty(input)),
};
Part::Tool(name.into(), body)
}
fn pre(class: &str, text: &str) -> String {
format!("<pre class=\"{class}\">{}</pre>", linked(text))
}
fn text<'a>(value: &'a Value, key: &str) -> &'a str {
value[key].as_str().unwrap_or("")
}
fn pretty(value: &Value) -> String {
value
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| serde_json::to_string_pretty(value).unwrap_or_default())
}