use agent_client_protocol::schema::v1::{Plan, ToolCall, ToolCallStatus};
use mj_core::state::{MaterializedSession, TerminalOutputRecord, TranscriptBody, TranscriptItem};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use crate::web::{BrowserDiffStat, BrowserTranscript, BrowserTranscriptEntry};
use mj_transcript::transcript::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TranscriptRenderMode {
Rich,
Raw,
}
impl TranscriptRenderMode {
pub fn toggled(self) -> Self {
match self {
Self::Rich => Self::Raw,
Self::Raw => Self::Rich,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryCollapse {
None,
Expanded,
Hidden,
Omitted,
Summary {
end: usize,
fingerprint: u64,
},
}
pub const BROWSER_TRANSCRIPT_LINES: usize = 1_000;
pub const BROWSER_LINE_BYTES: usize = 4 * 1024;
pub fn browser_transcript(
source_entries: &[ChatEntry],
latest_seq: u64,
last_compaction_seq: u64,
after_seq: Option<u64>,
) -> BrowserTranscript {
let collapsed_restarts =
collapsed_session_restart_states(source_entries, TranscriptRenderMode::Rich);
let collapse =
entry_collapse_states(source_entries, TranscriptRenderMode::Rich, &BTreeSet::new());
let restart_collapse_window_start = restart_collapse_window_start_seq(
source_entries,
&collapsed_restarts,
TranscriptRenderMode::Rich,
);
let mut entries = browser_projection_entries(source_entries, &collapse, last_compaction_seq)
.into_iter()
.map(|entry| browser_entry(&entry))
.collect::<Vec<_>>();
let mut remaining = BROWSER_TRANSCRIPT_LINES;
for entry in entries.iter_mut().rev() {
if entry.lines.len() > remaining {
let omitted = entry
.lines
.len()
.saturating_sub(remaining.saturating_sub(1));
if remaining == 0 {
entry.lines.clear();
} else {
entry.lines.drain(..omitted);
entry
.lines
.insert(0, format!("[… {omitted} earlier lines omitted …]"));
entry.lines.truncate(remaining);
}
}
remaining = remaining.saturating_sub(entry.lines.len());
}
entries.retain(|entry| !entry.lines.is_empty());
if remaining == 0 {
while entries.first().is_some_and(|entry| entry.lines.is_empty()) {
entries.remove(0);
}
}
let window_start_seq = entries
.iter()
.map(|entry| entry.id)
.min()
.unwrap_or(latest_seq)
.max(restart_collapse_window_start.unwrap_or_default());
let reset = after_seq.is_some_and(|after| after < window_start_seq);
if let Some(after) = after_seq.filter(|_| !reset) {
entries.retain(|entry| entry.updated_seq > after);
}
BrowserTranscript {
latest_seq,
presentation_key: rich_presentation_key(source_entries, &collapse, last_compaction_seq),
window_start_seq,
reset,
entries,
}
}
pub fn materialized_chat_entries(session: &MaterializedSession) -> Vec<ChatEntry> {
materialized_chat_entries_with_diffstats(session, &BTreeMap::new())
}
pub fn materialized_chat_entries_with_diffstats(
session: &MaterializedSession,
diffstats: &BTreeMap<String, Vec<String>>,
) -> Vec<ChatEntry> {
let mut entries = session
.transcript
.iter()
.map(|item| {
materialized_chat_entry_with_diffstats(
item,
session.applied_event_ordinal,
diffstats.get(&item.stable_id),
)
})
.collect::<Vec<_>>();
suppress_duplicate_standalone_terminal_output(&mut entries);
entries
}
pub const TAIL_SEED_ITEMS: usize = 256;
pub fn materialized_prefix_entries(items: &[Arc<TranscriptItem>], frontier: u64) -> Vec<ChatEntry> {
let mut entries = items
.iter()
.map(|item| materialized_chat_entry(item, frontier))
.collect::<Vec<_>>();
suppress_duplicate_standalone_terminal_output(&mut entries);
entries
}
pub fn materialized_chat_entries_reusing(
session: &MaterializedSession,
skip: usize,
previous: Vec<ChatEntry>,
) -> Vec<ChatEntry> {
let mut previous = previous.into_iter();
let mut entries = session
.transcript
.iter()
.skip(skip)
.map(|item| {
let Some(mut entry) = previous.next() else {
return materialized_chat_entry(item, session.applied_event_ordinal);
};
if entry.source.is(item) {
entry.seq = item_update_ordinal(item, session.applied_event_ordinal);
return entry;
}
if entry_matches_transcript_item(&entry, item) {
entry.seq = item_update_ordinal(item, session.applied_event_ordinal);
entry.source = TranscriptSource(Some(item.clone()));
return entry;
}
materialized_chat_entry(item, session.applied_event_ordinal)
})
.collect::<Vec<_>>();
suppress_duplicate_standalone_terminal_output(&mut entries);
entries
}
pub fn suppress_duplicate_standalone_terminal_output(entries: &mut [ChatEntry]) {
if !entries.iter().any(|entry| {
entry
.source
.0
.as_ref()
.is_some_and(|item| matches!(&item.body, TranscriptBody::TerminalOutput { .. }))
}) {
return;
}
let tools = entries
.iter()
.filter_map(|entry| entry.source.0.as_ref())
.filter(|item| matches!(&item.body, TranscriptBody::Tool { .. }))
.cloned()
.collect::<Vec<_>>();
for entry in entries {
let Some(item) = entry.source.0.as_ref() else {
continue;
};
let TranscriptBody::TerminalOutput { record } = &item.body else {
continue;
};
entry.raw_only = record.exited_cleanly()
|| tools.iter().any(|tool| {
let TranscriptBody::Tool { call, .. } = &tool.body else {
unreachable!("filtered to tool transcript items above");
};
record.matches_tool_raw_result(call)
});
}
}
pub fn entry_matches_transcript_item(entry: &ChatEntry, item: &TranscriptItem) -> bool {
entry.start_seq == item.position
&& entry.recorded_at_ms == Some(item.created_at_ms)
&& entry.revision == u64::try_from(item.last_changed_at_ms).unwrap_or_default()
&& entry.role == entry_role(item)
&& match &item.body {
TranscriptBody::Agent { .. } | TranscriptBody::Thought { .. } => {
entry.message_id.as_deref() == Some(item.stable_id.as_str())
}
TranscriptBody::Tool { .. } => {
entry.tool_call_id.as_deref() == Some(item.stable_id.as_str())
}
_ => true,
}
}
pub fn entry_role(item: &TranscriptItem) -> ChatRole {
match &item.body {
TranscriptBody::User { content } => {
if mj_core::second_opinion::is_control_origin_prompt(&materialized_content_text(
content,
)) {
ChatRole::System
} else {
ChatRole::User
}
}
TranscriptBody::Agent { .. } => ChatRole::Agent,
TranscriptBody::Thought { .. } => ChatRole::Thought,
TranscriptBody::Tool { .. } => ChatRole::Tool,
TranscriptBody::Plan { .. } => ChatRole::Plan,
TranscriptBody::PlanProposal { .. } => ChatRole::PlanProposal,
TranscriptBody::System { .. } | TranscriptBody::TerminalOutput { .. } => ChatRole::System,
}
}
pub fn materialized_chat_entry(item: &Arc<TranscriptItem>, frontier: u64) -> ChatEntry {
materialized_chat_entry_with_diffstats(item, frontier, None)
}
pub fn item_update_ordinal(item: &TranscriptItem, frontier: u64) -> u64 {
let latest = match &item.body {
TranscriptBody::User { .. }
| TranscriptBody::System { .. }
| TranscriptBody::PlanProposal { .. } => item.position,
TranscriptBody::Agent { .. } => item.latest_content_event_ordinal.unwrap_or(frontier),
TranscriptBody::Thought { .. }
| TranscriptBody::Tool { .. }
| TranscriptBody::Plan { .. }
| TranscriptBody::TerminalOutput { .. } => frontier,
};
latest.max(item.position)
}
pub fn materialized_chat_entry_with_diffstats(
item: &Arc<TranscriptItem>,
frontier: u64,
exact_diffstats: Option<&Vec<String>>,
) -> ChatEntry {
let mut entry = match &item.body {
TranscriptBody::User { content } => ChatEntry::plain(
item.position,
entry_role(item),
materialized_content_text(content),
),
TranscriptBody::Agent { chunks, .. } => ChatEntry::plain(
item.position,
ChatRole::Agent,
materialized_chunks_text(chunks),
),
TranscriptBody::Thought { chunks, .. } => ChatEntry::plain(
item.position,
ChatRole::Thought,
materialized_chunks_text(chunks),
),
TranscriptBody::Tool {
call,
terminal_outputs,
presentation,
..
} => {
let call = match ToolCall::deserialize(call) {
Ok(call) => Some(call),
Err(error) => {
tracing::warn!(
stable_id = %item.stable_id,
%error,
"could not decode a stored tool call; rendering it as invalid"
);
None
}
};
let mut entry = ChatEntry::tool(
item.position,
call.as_ref()
.map_or("[invalid tool call]", |call| call.title.as_str()),
Some(item.stable_id.clone()),
call.as_ref()
.map_or(ToolStatus::Pending, |call| tool_status(&call.status)),
);
if let Some(call) = call {
let presentation =
materialized_tool_call_presentation(presentation.as_deref(), &call);
entry.tool_summary = Some(presentation.summary.clone());
entry.tool_presentation = Some(presentation);
let fallback_terminal = mj_core::acp::is_fallback_terminal_tool_call(&call);
entry.tool_content =
tool_content_details(&call.content, terminal_outputs, call.raw_output.as_ref());
entry.tool_diffstats = exact_diffstats
.cloned()
.unwrap_or_else(|| tool_diff_paths(&call.content));
entry.tool_locations = tool_location_details(&call.locations);
entry.raw_only = fallback_terminal
&& !terminal_outputs.is_empty()
&& terminal_outputs
.iter()
.all(TerminalOutputRecord::exited_cleanly);
if fallback_terminal && call.status == ToolCallStatus::Failed {
let details = std::mem::take(&mut entry.tool_content);
if !details.is_empty() {
entry.text.push('\n');
entry.text.push_str(&details.join("\n"));
if let Some(summary) = &mut entry.tool_summary {
summary.push('\n');
summary.push_str(&details.join("\n"));
}
}
}
}
entry
}
TranscriptBody::TerminalOutput { record } => {
let mut entry = ChatEntry::plain(
item.position,
ChatRole::System,
sanitize_terminal_text(&terminal_output_detail(record)),
);
entry.raw_only = record.exited_cleanly();
entry
}
TranscriptBody::Plan { plan } => ChatEntry::plan(
item.position,
Plan::deserialize(plan)
.map(|plan| plan.entries)
.unwrap_or_else(|error| {
tracing::warn!(
stable_id = %item.stable_id,
%error,
"could not decode a stored plan; rendering it empty"
);
Vec::new()
})
.into_iter()
.map(|line| PlanLine {
text: sanitize_terminal_text(&line.content),
status: plan_status(&line.status),
})
.collect(),
),
TranscriptBody::PlanProposal { plan, .. } => {
ChatEntry::plain(item.position, ChatRole::PlanProposal, plan)
}
TranscriptBody::System { text } => ChatEntry::plain(item.position, ChatRole::System, text),
};
entry.seq = item_update_ordinal(item, frontier);
entry.recorded_at_ms = Some(item.created_at_ms);
entry.revision = u64::try_from(item.last_changed_at_ms).unwrap_or_default();
if matches!(
&item.body,
TranscriptBody::Agent { .. } | TranscriptBody::Thought { .. }
) {
entry.message_id = Some(item.stable_id.clone());
}
entry.source = TranscriptSource(Some(item.clone()));
entry
}
pub fn user_label(entry: &ChatEntry) -> &'static str {
if entry
.source
.0
.as_ref()
.and_then(|item| item.stable_id.strip_prefix("user:"))
.is_some_and(mj_core::relay::is_capacity_retry_command)
{
"Automatic · capacity retry"
} else {
"You"
}
}
pub fn browser_entry(entry: &ChatEntry) -> BrowserTranscriptEntry {
let (role, label) = match entry.role {
ChatRole::User => ("user", user_label(entry).to_owned()),
ChatRole::Agent => ("agent", "Agent".to_owned()),
ChatRole::Thought => ("thought", "Thinking".to_owned()),
ChatRole::Tool => (
"tool",
format!(
"Tool · {}",
tool_status_name(entry.tool_status.unwrap_or(ToolStatus::Pending))
),
),
ChatRole::Plan => ("plan", "Plan".to_owned()),
ChatRole::PlanProposal => ("plan-proposal", "Proposed plan".to_owned()),
ChatRole::System => ("system", "Mjolnir".to_owned()),
};
let source = if entry.role == ChatRole::Plan {
entry
.plan
.iter()
.map(|line| {
let marker = match line.status {
PlanStatus::Pending => "○",
PlanStatus::Running => "●",
PlanStatus::Completed => "✓",
};
format!("{marker} {}", line.text)
})
.collect::<Vec<_>>()
} else if entry.role == ChatRole::Tool {
std::iter::once(
entry
.tool_summary
.as_deref()
.unwrap_or(&entry.text)
.to_owned(),
)
.chain(entry.tool_diffstats.clone())
.collect()
} else {
entry.text.lines().map(str::to_owned).collect()
};
BrowserTranscriptEntry {
id: entry.start_seq,
updated_seq: entry.seq,
role,
label,
recorded_at_ms: entry.recorded_at_ms,
lines: source
.into_iter()
.map(|line| truncate_browser_line(&line))
.collect(),
glyph: entry_glyph(entry),
tone: entry_tone(entry),
tool_status: (entry.role == ChatRole::Tool)
.then(|| tool_status_name(entry.tool_status.unwrap_or(ToolStatus::Pending))),
diffstats: entry
.tool_diffstats
.iter()
.filter_map(|line| parse_diffstat(line))
.collect(),
}
}
pub fn browser_projection_entries(
entries: &[ChatEntry],
collapse: &[EntryCollapse],
last_compaction_seq: u64,
) -> Vec<ChatEntry> {
let mut projected = Vec::new();
let mut index = 0;
while index < entries.len() {
match collapse[index] {
EntryCollapse::None => {
let entry = &entries[index];
if entry.start_seq > last_compaction_seq && !entry.raw_only {
projected.push(entry.clone());
}
index += 1;
}
EntryCollapse::Expanded => {
let entry = &entries[index];
if entry.start_seq > last_compaction_seq && !entry.raw_only {
projected.push(entry.clone());
}
index += 1;
}
EntryCollapse::Omitted | EntryCollapse::Hidden => index += 1,
EntryCollapse::Summary { end, .. } => {
let members = entries[index..end]
.iter()
.filter(|entry| entry.start_seq > last_compaction_seq && !entry.raw_only)
.cloned()
.collect::<Vec<_>>();
projected.extend(collapsed_streak_entries(&members));
index = end;
}
}
}
projected
}
pub fn rich_presentation_key(
entries: &[ChatEntry],
collapse: &[EntryCollapse],
last_compaction_seq: u64,
) -> String {
let mut digest = Sha256::new();
digest.update(b"rich-presentation-v2");
let mut index = 0;
while index < entries.len() {
match collapse[index] {
EntryCollapse::Summary { end, .. } => {
if entries[index..end]
.iter()
.any(|entry| entry.start_seq > last_compaction_seq)
{
digest.update(b"S");
for entry in &entries[index..end] {
if entry.start_seq <= last_compaction_seq {
continue;
}
digest.update(if entry.raw_only { b"O" } else { b"M" });
digest.update(role_tag(entry.role));
digest.update(entry.start_seq.to_le_bytes());
}
}
index = end;
}
EntryCollapse::Hidden => {
if entries[index].start_seq <= last_compaction_seq {
index += 1;
continue;
}
digest.update(b"H");
digest.update(role_tag(entries[index].role));
digest.update(entries[index].start_seq.to_le_bytes());
index += 1;
}
EntryCollapse::Omitted => {
if entries[index].start_seq <= last_compaction_seq {
index += 1;
continue;
}
digest.update(b"O");
digest.update(role_tag(entries[index].role));
digest.update(entries[index].start_seq.to_le_bytes());
index += 1;
}
EntryCollapse::None | EntryCollapse::Expanded => index += 1,
}
}
format!("{:x}", digest.finalize())
}
pub fn role_tag(role: ChatRole) -> &'static [u8] {
match role {
ChatRole::User => b"user",
ChatRole::Agent => b"agent",
ChatRole::Thought => b"thought",
ChatRole::Tool => b"tool",
ChatRole::Plan => b"plan",
ChatRole::PlanProposal => b"plan-proposal",
ChatRole::System => b"system",
}
}
pub fn entry_tone(entry: &ChatEntry) -> &'static str {
match entry.role {
ChatRole::User => "user",
ChatRole::Agent => "agent",
ChatRole::Thought => "thinking",
ChatRole::Tool => match entry.tool_status.unwrap_or(ToolStatus::Pending) {
ToolStatus::Pending => "system",
ToolStatus::Running => "running",
ToolStatus::Completed => "done",
ToolStatus::Failed => "failed",
},
ChatRole::Plan => "plan",
ChatRole::PlanProposal => "plan-proposal",
ChatRole::System => "system",
}
}
pub fn parse_diffstat(line: &str) -> Option<BrowserDiffStat> {
let (path, counts) = line.rsplit_once(" ")?;
let (added, removed) = counts.split_once(' ')?;
Some(BrowserDiffStat {
path: path.trim().to_owned(),
insertions: added.strip_prefix('+')?.parse().ok()?,
deletions: removed
.strip_prefix('\u{2212}')
.or_else(|| removed.strip_prefix('-'))?
.parse()
.ok()?,
})
}
pub fn truncate_browser_line(line: &str) -> String {
if line.len() <= BROWSER_LINE_BYTES {
return line.to_owned();
}
const SUFFIX: &str = "… [truncated]";
let mut end = BROWSER_LINE_BYTES - SUFFIX.len();
while !line.is_char_boundary(end) {
end -= 1;
}
format!("{}{SUFFIX}", &line[..end])
}
pub const fn tool_status_name(status: ToolStatus) -> &'static str {
match status {
ToolStatus::Pending => "waiting",
ToolStatus::Running => "running",
ToolStatus::Completed => "done",
ToolStatus::Failed => "failed",
}
}
pub fn is_completed_tool(entry: &ChatEntry) -> bool {
entry.role == ChatRole::Tool && entry.tool_status == Some(ToolStatus::Completed)
}
pub fn collapse_revision_fingerprint(entries: &[ChatEntry]) -> u64 {
entries.iter().fold(0u64, |accumulated, entry| {
accumulated.wrapping_mul(31).wrapping_add(entry.revision)
})
}
pub fn collapsed_session_restart_states(
entries: &[ChatEntry],
mode: TranscriptRenderMode,
) -> Vec<bool> {
let mut hidden = vec![false; entries.len()];
let mut latest_restart = None;
for (index, entry) in entries.iter().enumerate() {
if mode == TranscriptRenderMode::Rich && entry.raw_only {
continue;
}
if entry.is_session_restart() {
if let Some(previous) = latest_restart {
hidden[previous] = true;
}
latest_restart = Some(index);
} else {
latest_restart = None;
}
}
hidden
}
pub fn restart_collapse_window_start_seq(
entries: &[ChatEntry],
collapsed_restarts: &[bool],
mode: TranscriptRenderMode,
) -> Option<u64> {
let mut latest_restart = None;
let mut boundary = None;
for (index, entry) in entries.iter().enumerate() {
if mode == TranscriptRenderMode::Rich && entry.raw_only {
continue;
}
if entry.is_session_restart() {
if latest_restart.is_some_and(|previous| collapsed_restarts[previous]) {
boundary = Some(boundary.map_or(entry.seq, |current: u64| current.max(entry.seq)));
}
latest_restart = Some(index);
} else {
latest_restart = None;
}
}
boundary
}
pub fn entry_collapse_states(
entries: &[ChatEntry],
mode: TranscriptRenderMode,
expanded_tool_calls: &BTreeSet<u64>,
) -> Vec<EntryCollapse> {
let mut states = vec![EntryCollapse::None; entries.len()];
if mode == TranscriptRenderMode::Rich {
for (index, entry) in entries.iter().enumerate() {
if entry.raw_only {
states[index] = EntryCollapse::Omitted;
} else if is_completed_tool(entry) && expanded_tool_calls.contains(&entry.start_seq) {
states[index] = EntryCollapse::Expanded;
}
}
}
if mode != TranscriptRenderMode::Rich {
for (index, hidden) in collapsed_session_restart_states(entries, mode)
.into_iter()
.enumerate()
{
if hidden {
states[index] = EntryCollapse::Hidden;
}
}
return states;
}
let streak_member = |index: usize| {
entries[index].role == ChatRole::Thought
|| (is_completed_tool(&entries[index])
&& !expanded_tool_calls.contains(&entries[index].start_seq))
};
let mut start = 0;
while start < entries.len() {
if !streak_member(start) {
start += 1;
continue;
}
let mut end = start + 1;
let mut cursor = start + 1;
while cursor < entries.len() {
if streak_member(cursor) {
cursor += 1;
end = cursor;
} else if entries[cursor].raw_only {
cursor += 1;
} else {
break;
}
}
let members = &entries[start..end];
let thoughts = members
.iter()
.filter(|entry| entry.role == ChatRole::Thought)
.count();
let tools = members
.iter()
.filter(|entry| is_completed_tool(entry))
.count();
let tool_precedes_thought = members
.iter()
.find(|entry| !entry.raw_only)
.is_some_and(|entry| entry.role == ChatRole::Tool)
&& thoughts > 0;
if thoughts > 1 || tools > 1 || tool_precedes_thought {
let fingerprint = collapse_revision_fingerprint(members);
states[start] = EntryCollapse::Summary { end, fingerprint };
states[start + 1..end].fill(EntryCollapse::Hidden);
}
start = end;
}
for (index, hidden) in collapsed_session_restart_states(entries, mode)
.into_iter()
.enumerate()
{
if hidden {
states[index] = EntryCollapse::Hidden;
}
}
states
}
pub fn collapsed_tool_entry(members: &[ChatEntry]) -> ChatEntry {
let tools = members
.iter()
.filter(|member| is_completed_tool(member))
.collect::<Vec<_>>();
let summaries = tools
.iter()
.map(|member| member.tool_summary.as_deref().unwrap_or(&member.text))
.collect::<Vec<_>>()
.join(", ");
let first = tools[0];
let mut summary = ChatEntry::tool(
first.start_seq,
summaries.clone(),
None,
ToolStatus::Completed,
);
summary.seq = members
.iter()
.map(|member| member.seq)
.max()
.unwrap_or(first.seq);
summary.revision = members
.iter()
.map(|member| member.revision)
.max()
.unwrap_or(first.revision);
summary.recorded_at_ms = tools.iter().rev().find_map(|member| member.recorded_at_ms);
summary.tool_summary = Some(summaries);
summary
}
pub fn collapsed_streak_entries(members: &[ChatEntry]) -> Vec<ChatEntry> {
let mut projected = Vec::new();
if let Some(thought) = members
.iter()
.rev()
.find(|member| member.role == ChatRole::Thought)
{
projected.push(thought.clone());
}
let tools = members
.iter()
.filter(|member| is_completed_tool(member))
.collect::<Vec<_>>();
if tools.len() >= 2 {
projected.push(collapsed_tool_entry(members));
} else {
projected.extend(tools.into_iter().cloned());
}
projected
}
pub fn entry_glyph(entry: &ChatEntry) -> &'static str {
match entry.role {
ChatRole::User => "❯",
ChatRole::Agent => "●",
ChatRole::Thought => "○",
ChatRole::Plan => "◇",
ChatRole::PlanProposal => "◈",
ChatRole::System => "─",
ChatRole::Tool => match entry.tool_status.unwrap_or(ToolStatus::Pending) {
ToolStatus::Pending => "•",
ToolStatus::Running => "●",
ToolStatus::Completed => "✓",
ToolStatus::Failed => "×",
},
}
}
pub fn materialized_browser_transcript(session: &MaterializedSession) -> BrowserTranscript {
browser_transcript(
&materialized_chat_entries(session),
session.applied_event_ordinal,
0,
None,
)
}