use koda_core::persistence::{Message, Role, SessionEvent, session_event_kind};
use koda_core::tools::{ToolEffect, classify_tool};
use std::collections::HashMap;
#[derive(Debug, Default)]
pub struct SessionMeta {
pub session_id: String,
pub title: Option<String>,
pub started_at: Option<String>,
pub model: String,
pub provider: String,
pub project_root: String,
}
const SUMMARY_RESULT_LINES: usize = 10;
const VERBOSE_RESULT_LINES: usize = 50;
const SUMMARY_BASH_CHARS: usize = 80;
const VERBOSE_BASH_CHARS: usize = 500;
const HYPERLINK_KILL_SWITCH: &str = "KODA_TRANSCRIPT_HYPERLINKS";
fn hyperlinks_enabled() -> bool {
!matches!(
koda_core::runtime_env::get(HYPERLINK_KILL_SWITCH).as_deref(),
Some("off" | "0" | "false" | "no")
)
}
fn format_utc_now() -> String {
let dt = crate::util::utc_now();
format!(
"{:04}-{:02}-{:02} {:02}:{:02} UTC",
dt.year(),
dt.month() as u8,
dt.day(),
dt.hour(),
dt.minute(),
)
}
fn extract_tool_call_meta(call: &serde_json::Value) -> (String, String, String) {
let id = call
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let name = call
.get("function_name")
.or_else(|| call.get("function").and_then(|f| f.get("name")))
.and_then(|v| v.as_str())
.unwrap_or("Tool")
.to_string();
let args = call
.get("arguments")
.or_else(|| call.get("function").and_then(|f| f.get("arguments")))
.and_then(|v| v.as_str())
.unwrap_or("{}")
.to_string();
(id, name, args)
}
pub fn render(
messages: &[Message],
events: &[SessionEvent],
meta: &SessionMeta,
verbose: bool,
) -> String {
let mut out = String::with_capacity(if verbose { 16384 } else { 4096 });
let title = meta.title.as_deref().unwrap_or("Koda Session");
let now = format_utc_now();
out.push_str(&format!("# {title} — {now}\n\n"));
if verbose {
render_metadata_table(&mut out, meta);
}
let mut tool_id_to_name: HashMap<String, String> = HashMap::new();
let mut events_by_parent: HashMap<&str, Vec<&SessionEvent>> = HashMap::new();
let mut top_level_events: Vec<&SessionEvent> = Vec::new();
for ev in events {
match ev.parent_tool_call_id.as_deref() {
Some(parent) => events_by_parent.entry(parent).or_default().push(ev),
None => top_level_events.push(ev),
}
}
for msg in messages {
match msg.role {
Role::System => {}
Role::User => {
out.push_str("---\n\n");
render_role_header(&mut out, "\u{1f9d1} User", msg, verbose);
if let Some(ref content) = msg.content {
out.push_str(content.trim());
out.push_str("\n\n");
}
}
Role::Assistant => {
render_role_header(&mut out, "🤖 Assistant", msg, verbose);
if let Some(ref thinking) = msg.thinking_content
&& !thinking.trim().is_empty()
{
out.push_str("> 💭 **Thinking**\n");
for line in thinking.trim().lines() {
out.push_str("> ");
out.push_str(line);
out.push('\n');
}
out.push('\n');
}
if let Some(ref content) = msg.content {
let trimmed = content.trim();
if !trimmed.is_empty() {
out.push_str(trimmed);
out.push_str("\n\n");
}
}
if let Some(ref tc_json) = msg.tool_calls
&& let Ok(calls) = serde_json::from_str::<Vec<serde_json::Value>>(tc_json)
{
let bash_limit = if verbose {
VERBOSE_BASH_CHARS
} else {
SUMMARY_BASH_CHARS
};
let link = hyperlinks_enabled();
for call in &calls {
let (id, name, args_json) = extract_tool_call_meta(call);
if !id.is_empty() && name != "Tool" {
tool_id_to_name.insert(id.clone(), name.clone());
}
let detail = tool_detail_markdown(
&name,
&args_json,
bash_limit,
&meta.project_root,
link,
);
let icon = tool_icon(&name);
out.push_str(&format!("### {icon} **{name}**"));
if !detail.is_empty() {
if detail.starts_with('[') {
out.push(' ');
out.push_str(&detail);
} else {
out.push_str(&format!(" `{detail}`"));
}
}
if !id.is_empty() {
out.push_str(&format!(" `{id}`"));
}
out.push('\n');
}
out.push('\n');
}
if verbose {
render_token_counts(&mut out, msg);
}
}
Role::Tool => {
let tool_name = msg
.tool_call_id
.as_deref()
.and_then(|id| tool_id_to_name.get(id))
.map(|s| s.as_str())
.unwrap_or("");
let content = msg.content.as_deref().unwrap_or("").trim();
let total_lines = content.lines().count();
if !content.is_empty() {
let effect = classify_tool(tool_name);
let max_lines = if verbose {
VERBOSE_RESULT_LINES
} else {
SUMMARY_RESULT_LINES
};
let show_content = verbose || effect == ToolEffect::ReadOnly;
if show_content {
if tool_name == "WaitTask"
&& let Some(pretty) =
pretty_wait_task_output(content, msg.tool_call_id.as_deref())
{
out.push_str(&pretty);
} else {
let header = match msg.tool_call_id.as_deref() {
Some(id) if !id.is_empty() => {
format!("**Output for `{id}`:**\n\n```\n")
}
_ => "**Output:**\n\n```\n".to_string(),
};
out.push_str(&header);
let preview_lines: Vec<&str> =
content.lines().take(max_lines).collect();
out.push_str(&preview_lines.join("\n"));
if total_lines > max_lines {
out.push_str(&format!(
"\n\u{2026} ({} more lines)",
total_lines - max_lines
));
}
out.push_str("\n```\n\n");
}
} else if total_lines > 0 {
out.push_str(&format!(
"> _{total_lines} line(s) of output \u{2014} run tool to see full result_\n\n"
));
}
}
if let Some(call_id) = msg.tool_call_id.as_deref()
&& let Some(events) = events_by_parent.get(call_id)
&& !events.is_empty()
{
out.push_str(&format!(
"<details><summary>\u{1f50d} Sub-agent trace ({} event{})</summary>\n\n",
events.len(),
if events.len() == 1 { "" } else { "s" },
));
out.push_str("```\n");
for ev in events {
out.push_str(&ev.payload);
out.push('\n');
}
out.push_str("```\n\n</details>\n\n");
}
}
}
}
if !top_level_events.is_empty() {
render_background_activity(&mut out, &top_level_events);
}
out
}
fn render_background_activity(out: &mut String, events: &[&SessionEvent]) {
out.push_str("---\n\n## \u{1f4ca} Background activity\n\n");
out.push_str(
"<sub>Engine events captured during the session (info messages, \
bg-task state transitions). Pre-#1108 these were sink-only and \
not exported.</sub>\n\n",
);
let verbose = std::env::var("KODA_EXPORT_VERBOSE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let mut iter_counts: std::collections::BTreeMap<u64, u32> = std::collections::BTreeMap::new();
for ev in events {
let ts = ev.created_at.as_deref().unwrap_or("");
match ev.kind.as_str() {
session_event_kind::INFO => {
out.push_str(&format!("- `{ts}` \u{2139}\u{fe0f} {}\n", ev.payload));
}
session_event_kind::BG_TASK_UPDATE => {
if !verbose
&& let Some((tid, iter)) = parse_running_iter(&ev.payload)
&& iter > 0
{
*iter_counts.entry(tid).or_insert(0) += 1;
continue;
}
let pretty =
pretty_bg_task_update(&ev.payload).unwrap_or_else(|| ev.payload.clone());
out.push_str(&format!("- `{ts}` \u{1f680} {pretty}\n"));
}
other => {
out.push_str(&format!("- `{ts}` `{other}` {}\n", ev.payload));
}
}
}
for (tid, count) in iter_counts {
out.push_str(&format!(
"- \u{1f4ad} agent:{tid}: {count} iteration{plural} aggregated \
(set `KODA_EXPORT_VERBOSE=1` to expand)\n",
plural = if count == 1 { "" } else { "s" },
));
}
out.push('\n');
}
fn parse_running_iter(payload: &str) -> Option<(u64, u32)> {
let v: serde_json::Value = serde_json::from_str(payload).ok()?;
let task_id = v.get("task_id")?.as_u64()?;
let iter = v
.get("status")?
.as_object()?
.get("Running")?
.as_object()?
.get("iter")?
.as_u64()?;
Some((task_id, iter as u32))
}
fn pretty_bg_task_update(payload: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(payload).ok()?;
let task_id = v.get("task_id")?.as_u64()?;
let status = v.get("status")?;
let status_str = match status {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Object(map) => map
.iter()
.map(|(k, v)| format!("{k}({v})"))
.collect::<Vec<_>>()
.join(", "),
_ => status.to_string(),
};
Some(format!("agent:{task_id}: {status_str}"))
}
use crate::wait_task_format::{first_meaningful_line, wait_status_icon};
fn pretty_wait_task_output(payload: &str, tool_call_id: Option<&str>) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(payload).ok()?;
let tasks = v.get("tasks")?.as_array()?;
if tasks.is_empty() {
return None;
}
let summary = v.get("summary").and_then(|s| s.as_object());
let count = |k: &str| -> u64 {
summary
.and_then(|s| s.get(k))
.and_then(|v| v.as_u64())
.unwrap_or(0)
};
let total = summary
.and_then(|s| s.get("total"))
.and_then(|v| v.as_u64())
.unwrap_or(tasks.len() as u64);
let mut summary_parts: Vec<String> = Vec::new();
for (key, label) in [
("completed", "completed"),
("timed_out", "timed out"),
("cancelled", "cancelled"),
("not_found", "not found"),
("forbidden", "forbidden"),
("parse_error", "parse error"),
] {
let n = count(key);
if n > 0 {
summary_parts.push(format!("{} {n} {label}", wait_status_icon(key)));
}
}
let summary_suffix = if summary_parts.is_empty() {
String::new()
} else {
format!(" \u{2014} {}", summary_parts.join(" \u{00B7} "))
};
let mut out = String::new();
let header = match tool_call_id {
Some(id) if !id.is_empty() => format!(
"**Output for `{id}`** \u{2014} `WaitTask` gathered {total} task(s){summary_suffix}\n\n"
),
_ => format!("**Output** \u{2014} `WaitTask` gathered {total} task(s){summary_suffix}\n\n"),
};
out.push_str(&header);
for task in tasks {
let task_id = task.get("task_id").and_then(|v| v.as_str()).unwrap_or("?");
let status = task.get("status").and_then(|v| v.as_str()).unwrap_or("?");
let icon = wait_status_icon(status);
let agent_name = task
.get("agent_name")
.and_then(|v| v.as_str())
.unwrap_or("");
let agent_label = if agent_name.is_empty() {
String::new()
} else {
format!(" \u{2014} <code>{agent_name}</code>")
};
let preview = task
.get("output")
.and_then(|v| v.as_str())
.map(|s| first_meaningful_line(s, 120))
.unwrap_or_default();
let preview_html = if preview.is_empty() {
String::new()
} else {
let safe = preview
.replace('&', "&")
.replace('<', "<")
.replace('>', ">");
format!(" \u{2014} <i>{safe}</i>")
};
out.push_str(&format!(
"<details>\n<summary><b>{task_id}</b>{agent_label} {icon} {status}{preview_html}</summary>\n\n"
));
if let Some(prompt) = task.get("prompt").and_then(|v| v.as_str()) {
let one_liner = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = if one_liner.chars().count() > 200 {
let head: String = one_liner.chars().take(200).collect();
format!("{head}\u{2026}")
} else {
one_liner
};
out.push_str(&format!("> _Prompt:_ {trimmed}\n\n"));
}
if let Some(output) = task.get("output").and_then(|v| v.as_str()) {
out.push_str(output.trim_end());
out.push_str("\n\n");
} else if let Some(err) = task.get("error").and_then(|v| v.as_str()) {
out.push_str(&format!("> \u{26A0} _{err}_\n\n"));
}
if let Some(events) = task.get("events").and_then(|v| v.as_array())
&& !events.is_empty()
{
out.push_str(&format!("_Tool calls ({}):_ ", events.len()));
let names: Vec<String> = events
.iter()
.filter_map(|e| e.as_str())
.map(|s| format!("`{}`", s.trim()))
.collect();
out.push_str(&names.join(" \u{00B7} "));
out.push_str("\n\n");
}
out.push_str("</details>\n\n");
}
Some(out)
}
fn render_metadata_table(out: &mut String, meta: &SessionMeta) {
out.push_str("| Field | Value |\n|---|---|\n");
out.push_str(&format!("| Session | `{}` |\n", meta.session_id));
out.push_str(&format!("| Model | {} |\n", meta.model));
out.push_str(&format!("| Provider | {} |\n", meta.provider));
out.push_str(&format!("| Project | `{}` |\n", meta.project_root));
if let Some(ref started) = meta.started_at {
out.push_str(&format!("| Started | {started} |\n"));
}
out.push('\n');
}
fn render_role_header(out: &mut String, label: &str, msg: &Message, verbose: bool) {
out.push_str(&format!("## {label}"));
if verbose && let Some(ref ts) = msg.created_at {
let time_part = ts.split('T').nth(1).and_then(|t| t.get(..8)).unwrap_or(ts);
out.push_str(&format!(" <sub>{time_part}</sub>"));
}
out.push_str("\n\n");
}
fn render_token_counts(out: &mut String, msg: &Message) {
let mut parts: Vec<String> = Vec::new();
if let Some(p) = msg.prompt_tokens {
parts.push(format!("{p} prompt"));
}
if let Some(c) = msg.completion_tokens {
parts.push(format!("{c} completion"));
}
if let Some(cr) = msg.cache_read_tokens
&& cr > 0
{
parts.push(format!("{cr} cache-read"));
}
if let Some(cc) = msg.cache_creation_tokens
&& cc > 0
{
parts.push(format!("{cc} cache-write"));
}
if let Some(t) = msg.thinking_tokens
&& t > 0
{
parts.push(format!("{t} thinking"));
}
if !parts.is_empty() {
out.push_str(&format!("<sub>tokens: {}</sub>\n\n", parts.join(" · ")));
}
}
fn tool_icon(name: &str) -> &'static str {
match name {
"Read" => "📄",
"Write" => "✏️",
"Edit" => "✏️",
"Delete" => "🗑️",
"Bash" => "💻",
"Grep" => "🔍",
"List" | "Glob" => "📁",
"WebFetch" => "🌐",
"TodoWrite" => "📋",
"MemoryWrite" | "MemoryRead" => "🧠",
"InvokeAgent" => "🤖",
"AskUser" => "💬",
_ => "🔧",
}
}
fn tool_detail_markdown(
name: &str,
args_json: &str,
bash_limit: usize,
project_root: &str,
link: bool,
) -> String {
let args: serde_json::Value =
serde_json::from_str(args_json).unwrap_or(serde_json::Value::Null);
let raw = crate::tool_header::detail_text(name, &args, bash_limit);
if !link || raw.is_empty() {
return raw;
}
match name {
"Read" | "Write" | "Edit" | "Delete" => {
let abs = absolute_path(&raw, project_root);
format!("[{raw}]({})", file_uri(&abs))
}
"WebFetch" => format!("[{raw}]({raw})"),
_ => raw,
}
}
fn absolute_path(path: &str, project_root: &str) -> String {
if path.starts_with('/') || project_root.is_empty() {
return path.to_string();
}
let root = project_root.trim_end_matches('/');
format!("{root}/{path}")
}
fn file_uri(abs_path: &str) -> String {
let mut out = String::with_capacity(abs_path.len() + 8);
out.push_str("file://");
if !abs_path.starts_with('/') {
out.push('/');
}
for ch in abs_path.chars() {
match ch {
' ' => out.push_str("%20"),
'(' => out.push_str("%28"),
')' => out.push_str("%29"),
'[' => out.push_str("%5B"),
']' => out.push_str("%5D"),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use koda_core::persistence::Message;
static HYPERLINK_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn make_msg(role: Role, content: &str) -> Message {
Message {
id: 0,
session_id: "test".into(),
role,
content: Some(content.into()),
full_content: None,
tool_calls: None,
tool_call_id: None,
prompt_tokens: None,
completion_tokens: None,
cache_read_tokens: None,
cache_creation_tokens: None,
thinking_tokens: None,
thinking_content: None,
created_at: None,
}
}
fn default_meta() -> SessionMeta {
SessionMeta {
session_id: "test-session".into(),
title: None,
started_at: None,
model: "test-model".into(),
provider: "test-provider".into(),
project_root: "/tmp/project".into(),
}
}
#[test]
fn empty_messages_produces_header_only() {
let meta = SessionMeta {
title: Some("Test Session".into()),
..default_meta()
};
let out = render(&[], &[], &meta, false);
assert!(out.contains("# Test Session"));
assert!(!out.contains("🧑 User"));
}
#[test]
fn user_message_renders_correctly() {
let msgs = vec![make_msg(Role::User, "hello koda")];
let out = render(&msgs, &[], &default_meta(), false);
assert!(out.contains("🧑 User"));
assert!(out.contains("hello koda"));
}
#[test]
fn assistant_message_renders_correctly() {
let msgs = vec![make_msg(Role::Assistant, "I can help!")];
let out = render(&msgs, &[], &default_meta(), false);
assert!(out.contains("🤖 Assistant"));
assert!(out.contains("I can help!"));
}
#[test]
fn system_messages_skipped() {
let msgs = vec![
make_msg(Role::System, "secret prompt"),
make_msg(Role::User, "hi"),
];
let out = render(&msgs, &[], &default_meta(), false);
assert!(!out.contains("secret prompt"));
}
#[test]
fn tool_read_result_shown_as_code_block() {
let mut result_msg = make_msg(Role::Tool, "fn main() {}\n");
result_msg.tool_call_id = Some("call_1".into());
let mut assistant_msg = make_msg(Role::Assistant, "");
assistant_msg.tool_calls = Some(flat_tool_calls_json(&[(
"call_1",
"Read",
r#"{"file_path":"src/main.rs"}"#,
)]));
let msgs = vec![assistant_msg, result_msg];
let out = render(&msgs, &[], &default_meta(), false);
assert!(out.contains("```"));
assert!(out.contains("fn main()"));
}
#[test]
fn bash_result_shows_summary_not_content() {
let mut result_msg = make_msg(Role::Tool, "line1\nline2\nline3");
result_msg.tool_call_id = Some("call_2".into());
let mut assistant_msg = make_msg(Role::Assistant, "");
assistant_msg.tool_calls = Some(flat_tool_calls_json(&[(
"call_2",
"Bash",
r#"{"command":"ls"}"#,
)]));
let msgs = vec![assistant_msg, result_msg];
let out = render(&msgs, &[], &default_meta(), false);
assert!(!out.contains("line1"));
assert!(out.contains("3 line(s) of output"));
}
#[test]
fn thinking_content_renders_as_blockquote() {
let mut msg = make_msg(Role::Assistant, "The answer is 42.");
msg.thinking_content = Some("Let me think step by step: 6 x 7 = 42.".into());
let out = render(&[msg], &[], &default_meta(), false);
assert!(
out.contains("The answer is 42."),
"response text must appear"
);
assert!(
out.contains("Thinking"),
"thinking block header must appear in transcript"
);
assert!(
out.contains("Let me think step by step"),
"thinking content must appear in transcript",
);
}
#[test]
fn verbose_header_includes_metadata() {
let meta = SessionMeta {
session_id: "sess-42".into(),
title: Some("Debug Session".into()),
started_at: Some("2026-04-14T12:00:00Z".into()),
model: "claude-sonnet-4-20250514".into(),
provider: "anthropic".into(),
project_root: "/home/user/project".into(),
};
let out = render(&[], &[], &meta, true);
assert!(out.contains("sess-42"), "session ID in header");
assert!(out.contains("claude-sonnet-4-20250514"), "model in header");
assert!(out.contains("anthropic"), "provider in header");
assert!(out.contains("/home/user/project"), "project root in header");
}
#[test]
fn verbose_shows_token_counts() {
let mut msg = make_msg(Role::Assistant, "The answer.");
msg.prompt_tokens = Some(100);
msg.completion_tokens = Some(50);
msg.cache_read_tokens = Some(80);
let out = render(&[msg], &[], &default_meta(), true);
assert!(out.contains("100 prompt"), "prompt tokens shown");
assert!(out.contains("50 completion"), "completion tokens shown");
assert!(out.contains("80 cache-read"), "cache-read tokens shown");
}
#[test]
fn summary_hides_token_counts() {
let mut msg = make_msg(Role::Assistant, "The answer.");
msg.prompt_tokens = Some(100);
msg.completion_tokens = Some(50);
let out = render(&[msg], &[], &default_meta(), false);
assert!(!out.contains("100 prompt"));
}
#[test]
fn verbose_shows_timestamps() {
let mut msg = make_msg(Role::User, "hello");
msg.created_at = Some("2026-04-14T09:15:30Z".into());
let out = render(&[msg], &[], &default_meta(), true);
assert!(out.contains("09:15:30"), "timestamp shown in verbose");
}
#[test]
fn summary_hides_timestamps() {
let mut msg = make_msg(Role::User, "hello");
msg.created_at = Some("2026-04-14T09:15:30Z".into());
let out = render(&[msg], &[], &default_meta(), false);
assert!(!out.contains("09:15:30"), "timestamp hidden in summary");
}
#[test]
fn bash_result_shown_in_verbose_mode() {
let mut result_msg = make_msg(Role::Tool, "line1\nline2\nline3");
result_msg.tool_call_id = Some("call_3".into());
let mut assistant_msg = make_msg(Role::Assistant, "");
assistant_msg.tool_calls = Some(flat_tool_calls_json(&[(
"call_3",
"Bash",
r#"{"command":"ls"}"#,
)]));
let msgs = vec![assistant_msg, result_msg];
let out = render(&msgs, &[], &default_meta(), true);
assert!(out.contains("line1"));
assert!(out.contains("line3"));
}
fn flat_tool_calls_json(calls: &[(&str, &str, &str)]) -> String {
use koda_core::providers::ToolCall;
let toolcalls: Vec<ToolCall> = calls
.iter()
.map(|(id, name, args)| ToolCall {
id: (*id).to_string(),
function_name: (*name).to_string(),
arguments: (*args).to_string(),
thought_signature: None,
})
.collect();
serde_json::to_string(&toolcalls).expect("ToolCall serializes")
}
fn assistant_with_call(name: &str, args_json: &str) -> Message {
let mut m = make_msg(Role::Assistant, "");
m.tool_calls = Some(flat_tool_calls_json(&[("c1", name, args_json)]));
m
}
#[test]
fn read_path_emits_markdown_link_with_file_uri() {
let _g = HYPERLINK_ENV_LOCK.lock().unwrap();
let msg = assistant_with_call("Read", r#"{"file_path":"src/main.rs"}"#);
let meta = SessionMeta {
project_root: "/home/user/proj".into(),
..default_meta()
};
let out = render(&[msg], &[], &meta, false);
assert!(
out.contains("[src/main.rs](file:///home/user/proj/src/main.rs)"),
"relative path should resolve under project_root, got:\n{out}"
);
}
#[test]
fn absolute_read_path_skips_root_join() {
let _g = HYPERLINK_ENV_LOCK.lock().unwrap();
let msg = assistant_with_call("Read", r#"{"file_path":"/etc/hosts"}"#);
let meta = SessionMeta {
project_root: "/home/user/proj".into(),
..default_meta()
};
let out = render(&[msg], &[], &meta, false);
assert!(
out.contains("[/etc/hosts](file:///etc/hosts)"),
"absolute path should pass through, got:\n{out}"
);
}
#[test]
fn webfetch_url_becomes_self_link() {
let _g = HYPERLINK_ENV_LOCK.lock().unwrap();
let msg = assistant_with_call("WebFetch", r#"{"url":"https://example.com/x"}"#);
let out = render(&[msg], &[], &default_meta(), false);
assert!(
out.contains("[https://example.com/x](https://example.com/x)"),
"URL should be a markdown self-link, got:\n{out}"
);
}
#[test]
fn bash_detail_stays_plain_codespan() {
let msg = assistant_with_call("Bash", r#"{"command":"git status"}"#);
let out = render(&[msg], &[], &default_meta(), false);
assert!(out.contains("`git status`"), "got:\n{out}");
assert!(
!out.contains("](git status)"),
"bash should never be linked"
);
}
#[test]
fn grep_detail_stays_plain_codespan() {
let msg = assistant_with_call("Grep", r#"{"search_string":"TODO","directory":"src"}"#);
let out = render(&[msg], &[], &default_meta(), false);
assert!(out.contains("`\"TODO\" in src`"), "got:\n{out}");
}
#[test]
fn kill_switch_disables_hyperlinks() {
let _g = HYPERLINK_ENV_LOCK.lock().unwrap();
koda_core::runtime_env::set(HYPERLINK_KILL_SWITCH, "off");
let msg = assistant_with_call("Read", r#"{"file_path":"/x.rs"}"#);
let out = render(&[msg], &[], &default_meta(), false);
koda_core::runtime_env::remove(HYPERLINK_KILL_SWITCH);
assert!(out.contains("`/x.rs`"), "plain text expected, got:\n{out}");
assert!(!out.contains("file:///"), "link should be suppressed");
}
#[test]
fn dry_equivalence_with_tool_header_detail_text() {
use crate::tool_header::detail_text;
let cases: Vec<(&str, serde_json::Value, usize)> = vec![
("Read", serde_json::json!({"file_path": "a.rs"}), 80),
("Bash", serde_json::json!({"command": "echo hi"}), 80),
(
"Grep",
serde_json::json!({"search_string": "x", "directory": "."}),
80,
),
("Glob", serde_json::json!({"pattern": "**/*.rs"}), 80),
("List", serde_json::json!({"directory": "src"}), 80),
("WebFetch", serde_json::json!({"url": "https://x"}), 80),
];
for (name, args, bash) in cases {
let from_helper = detail_text(name, &args, bash);
let from_transcript = tool_detail_markdown(name, &args.to_string(), bash, "", false);
assert_eq!(
from_helper, from_transcript,
"transcript detail must match tool_header::detail_text for {name}"
);
}
}
#[test]
fn file_uri_percent_encodes_breaking_chars() {
let uri = file_uri("/My Files/[draft].md");
assert_eq!(uri, "file:///My%20Files/%5Bdraft%5D.md");
}
#[test]
fn production_tool_calls_json_renders_tool_name_in_header() {
let mut msg = make_msg(Role::Assistant, "");
msg.tool_calls = Some(flat_tool_calls_json(&[(
"call_xyz",
"InvokeAgent",
r#"{"agent_name":"explore","prompt":"map the repo"}"#,
)]));
let out = render(&[msg], &[], &default_meta(), false);
assert!(
out.contains("**InvokeAgent**"),
"production-shape tool_calls must render the tool NAME in the header. \
Pre-#1108 every real export rendered `### 🔧 **Tool**` because the \
renderer read the OpenAI-nested shape (`function.name`) while \
persistence wrote the flat shape (`function_name`). got:\n{out}"
);
assert!(
!out.contains("**Tool**"),
"`**Tool**` is the silent fallback that masked the bug for months. \
It should never appear when the call has a real function_name. \
got:\n{out}"
);
}
#[test]
fn production_tool_calls_json_renders_tool_args_in_header() {
let mut msg = make_msg(Role::Assistant, "");
msg.tool_calls = Some(flat_tool_calls_json(&[(
"call_zzz",
"Read",
r#"{"file_path":"src/very_distinctive_file.rs"}"#,
)]));
let out = render(&[msg], &[], &default_meta(), false);
assert!(
out.contains("very_distinctive_file.rs"),
"production-shape tool_calls must surface the arguments. \
Pre-#1108 args were swallowed because the renderer read \
`function.arguments` (nested) while persistence wrote \
`arguments` (flat). got:\n{out}"
);
}
#[test]
fn tool_call_id_appears_in_header_for_correlation() {
let mut msg = make_msg(Role::Assistant, "");
msg.tool_calls = Some(flat_tool_calls_json(&[
(
"call_a",
"InvokeAgent",
r#"{"agent_name":"explore","prompt":"a"}"#,
),
(
"call_b",
"InvokeAgent",
r#"{"agent_name":"explore","prompt":"b"}"#,
),
]));
let out = render(&[msg], &[], &default_meta(), false);
assert!(
out.contains("call_a") && out.contains("call_b"),
"both tool_call_ids must appear in the header so parallel \
InvokeAgent calls can be matched to their Output rows. \
got:\n{out}"
);
}
#[test]
fn tool_call_id_appears_in_result_output_header() {
let mut a = make_msg(Role::Assistant, "");
a.tool_calls = Some(flat_tool_calls_json(&[(
"call_corr",
"Read",
r#"{"file_path":"x.rs"}"#,
)]));
let mut t = make_msg(Role::Tool, "file contents here");
t.tool_call_id = Some("call_corr".into());
let out = render(&[a, t], &[], &default_meta(), false);
assert!(
out.contains("call_corr"),
"the result row's Output header must mention its tool_call_id \
so parallel call/result pairs can be matched. got:\n{out}"
);
}
fn ev(kind: &str, payload: &str, parent: Option<&str>) -> SessionEvent {
SessionEvent {
id: 0,
session_id: "sess".into(),
kind: kind.into(),
payload: payload.into(),
parent_tool_call_id: parent.map(str::to_string),
created_at: Some("2026-04-27 06:00:00".into()),
}
}
#[test]
fn sub_agent_events_fold_under_matching_tool_result() {
let mut a = make_msg(Role::Assistant, "");
a.tool_calls = Some(flat_tool_calls_json(&[(
"call_inv",
"InvokeAgent",
r#"{"agent_name":"explore","prompt":"go"}"#,
)]));
let mut t = make_msg(Role::Tool, "sub-agent finished");
t.tool_call_id = Some("call_inv".into());
let events = vec![
ev(
session_event_kind::SUB_AGENT_EVENT,
" \u{1f527} Read foo.rs",
Some("call_inv"),
),
ev(
session_event_kind::SUB_AGENT_EVENT,
" \u{1f4ad} Looking at imports\u{2026}",
Some("call_inv"),
),
];
let out = render(&[a, t], &events, &default_meta(), true);
assert!(
out.contains("<details><summary>"),
"sub-agent events must be folded in a <details> block. got:\n{out}"
);
assert!(
out.contains("Sub-agent trace (2 events)"),
"summary must show the folded event count"
);
assert!(out.contains("Read foo.rs"));
assert!(out.contains("Looking at imports"));
}
#[test]
fn sub_agent_events_skipped_when_no_matching_tool_call_id() {
let mut t = make_msg(Role::Tool, "some output");
t.tool_call_id = Some("call_OTHER".into());
let events = vec![ev(
session_event_kind::SUB_AGENT_EVENT,
"orphan trace line",
Some("call_X"),
)];
let out = render(&[t], &events, &default_meta(), true);
assert!(
!out.contains("orphan trace line"),
"orphan parented events must not surface anywhere. got:\n{out}"
);
assert!(
!out.contains("<details>"),
"no details block when no events match the tool result"
);
}
#[test]
fn top_level_events_appear_in_background_activity_section() {
let events = vec![
ev(session_event_kind::INFO, "context compacted", None),
ev(
session_event_kind::BG_TASK_UPDATE,
r#"{"task_id":7,"status":"Pending"}"#,
None,
),
];
let out = render(&[], &events, &default_meta(), true);
assert!(
out.contains("Background activity"),
"top-level events must trigger the trailing section. got:\n{out}"
);
assert!(out.contains("context compacted"));
assert!(
out.contains("agent:7: Pending"),
"BgTaskUpdate JSON must be pretty-printed in canonical agent:N form (#1158 e). got:\n{out}"
);
}
#[test]
fn iter_heartbeats_aggregated_into_summary_line_by_default() {
let events = vec![
ev(
session_event_kind::BG_TASK_UPDATE,
r#"{"task_id":1,"status":"Pending"}"#,
None,
),
ev(
session_event_kind::BG_TASK_UPDATE,
r#"{"task_id":1,"status":{"Running":{"iter":0}}}"#,
None,
),
ev(
session_event_kind::BG_TASK_UPDATE,
r#"{"task_id":1,"status":{"Running":{"iter":1}}}"#,
None,
),
ev(
session_event_kind::BG_TASK_UPDATE,
r#"{"task_id":1,"status":{"Running":{"iter":2}}}"#,
None,
),
ev(
session_event_kind::BG_TASK_UPDATE,
r#"{"task_id":1,"status":{"Running":{"iter":3}}}"#,
None,
),
ev(
session_event_kind::BG_TASK_UPDATE,
r#"{"task_id":1,"status":{"Completed":{"summary":"done"}}}"#,
None,
),
];
let out = render(&[], &events, &default_meta(), true);
assert!(out.contains("agent:1: Pending"), "missing Pending: {out}");
assert!(
out.contains("\"iter\":0"),
"iter==0 marker must survive (treated as state transition): {out}"
);
assert!(out.contains("Completed"), "Completed must survive: {out}");
assert!(
!out.contains("\"iter\":1"),
"iter=1 heartbeat should be aggregated, not shown raw: {out}"
);
assert!(
!out.contains("\"iter\":2"),
"iter=2 heartbeat should be aggregated: {out}"
);
assert!(
out.contains("agent:1: 3 iterations aggregated"),
"missing per-task summary line: {out}"
);
}
#[test]
fn verbose_mode_re_emits_every_iter_heartbeat() {
unsafe {
std::env::set_var("KODA_EXPORT_VERBOSE", "1");
}
let events = vec![
ev(
session_event_kind::BG_TASK_UPDATE,
r#"{"task_id":1,"status":{"Running":{"iter":1}}}"#,
None,
),
ev(
session_event_kind::BG_TASK_UPDATE,
r#"{"task_id":1,"status":{"Running":{"iter":2}}}"#,
None,
),
];
let out = render(&[], &events, &default_meta(), true);
unsafe {
std::env::remove_var("KODA_EXPORT_VERBOSE");
}
assert!(
out.contains("\"iter\":1") && out.contains("\"iter\":2"),
"verbose mode must preserve all heartbeats: {out}"
);
assert!(
!out.contains("aggregated"),
"verbose mode must NOT emit summary line: {out}"
);
}
#[test]
fn no_background_activity_section_when_no_top_level_events() {
let out = render(&[], &[], &default_meta(), true);
assert!(
!out.contains("Background activity"),
"empty events must not produce a noisy empty section"
);
}
#[test]
fn pretty_bg_task_update_falls_back_to_raw_on_bad_json() {
let events = vec![ev(
session_event_kind::BG_TASK_UPDATE,
"not json at all {{{",
None,
)];
let out = render(&[], &events, &default_meta(), true);
assert!(
out.contains("not json at all {{{"),
"unparseable BgTaskUpdate must surface verbatim. got:\n{out}"
);
}
fn wait_task_result(call_id: &str, payload: serde_json::Value) -> Message {
let mut m = make_msg(Role::Tool, &payload.to_string());
m.tool_call_id = Some(call_id.into());
m
}
fn assistant_calling_wait_task(call_id: &str) -> Message {
let calls = serde_json::json!([{
"id": call_id,
"function": {"name": "WaitTask", "arguments": "{}"}
}]);
let mut m = make_msg(Role::Assistant, "");
m.tool_calls = Some(calls.to_string());
m
}
#[test]
fn wait_task_result_renders_as_collapsible_per_task_blocks_not_json_blob() {
let payload = serde_json::json!({
"summary": {"total": 2, "completed": 2},
"tasks": [
{
"task_id": "agent:1",
"status": "completed",
"agent_name": "explore",
"prompt": "Scan koda-core/",
"output": "### Key Files\n- foo.rs\n- bar.rs",
"events": [" \u{1F527} Read", " \u{1F527} Grep"],
},
{
"task_id": "agent:2",
"status": "completed",
"agent_name": "explore",
"prompt": "Scan koda-cli/",
"output": "### TUI findings\nLooks fine.",
"events": [],
},
],
});
let msgs = vec![
assistant_calling_wait_task("call_xyz"),
wait_task_result("call_xyz", payload),
];
let out = render(&msgs, &[], &default_meta(), false);
assert!(
out.contains("Output for `call_xyz`") && out.contains("gathered 2 task(s)"),
"header missing/wrong: {out}"
);
assert!(out.contains("2 completed"), "summary tally absent: {out}");
assert_eq!(
out.matches("<details>").count(),
2,
"expected 2 collapsible task blocks: {out}"
);
assert!(out.contains("<b>agent:1</b>") && out.contains("<b>agent:2</b>"));
assert!(out.contains("### Key Files"));
assert!(out.contains("### TUI findings"));
assert!(out.contains("Tool calls (2)"));
assert!(
!out.contains("Tool calls (0)"),
"empty event list shouldn't render a 'Tool calls (0)' line: {out}"
);
assert!(
!out.contains("```\n{\"summary\"") && !out.contains("```\n{\"tasks\""),
"raw WaitTask JSON must not be rendered as a code block: {out}"
);
}
#[test]
fn wait_task_pretty_renders_mixed_status_with_per_task_icons() {
let payload = serde_json::json!({
"summary": {"total": 3, "timed_out": 1, "cancelled": 1, "not_found": 1},
"tasks": [
{"task_id": "agent:5", "status": "timed_out", "current": {}},
{"task_id": "agent:6", "status": "cancelled"},
{"task_id": "agent:99", "status": "not_found", "error": "unknown task agent:99"},
],
});
let msgs = vec![
assistant_calling_wait_task("c1"),
wait_task_result("c1", payload),
];
let out = render(&msgs, &[], &default_meta(), false);
assert!(out.contains("\u{23F1}"), "timed_out icon missing: {out}");
assert!(out.contains("\u{26D4}"), "cancelled icon missing: {out}");
assert!(out.contains("\u{2753}"), "not_found icon missing: {out}");
assert!(
out.contains("unknown task agent:99"),
"not_found error must be visible: {out}"
);
}
#[test]
fn wait_task_pretty_falls_back_to_code_block_on_unparseable_payload() {
let mut bad = make_msg(Role::Tool, "{not valid json");
bad.tool_call_id = Some("c1".into());
let msgs = vec![assistant_calling_wait_task("c1"), bad];
let out = render(&msgs, &[], &default_meta(), false);
assert!(
out.contains("{not valid json"),
"unparseable WaitTask payload must surface verbatim: {out}"
);
assert!(
!out.contains("<details>"),
"pretty path must abort cleanly on parse failure: {out}"
);
}
#[test]
fn wait_task_pretty_survives_gemini_style_tool_id_reuse_across_turns() {
let payload = serde_json::json!({
"summary": {"total": 1, "completed": 1},
"tasks": [{
"task_id": "agent:1",
"status": "completed",
"agent_name": "explore",
"output": "All clear.",
}],
});
let read_call = serde_json::json!([{
"id": "tc_1",
"function": {"name": "Read", "arguments": "{\"file_path\":\"a.rs\"}"}
}]);
let mut read_assistant = make_msg(Role::Assistant, "");
read_assistant.tool_calls = Some(read_call.to_string());
let mut read_result = make_msg(Role::Tool, "fn main() {}\n");
read_result.tool_call_id = Some("tc_1".into());
let msgs = vec![
assistant_calling_wait_task("tc_1"),
wait_task_result("tc_1", payload),
read_assistant,
read_result,
];
let out = render(&msgs, &[], &default_meta(), false);
assert!(
out.contains("<details>") && out.contains("agent:1"),
"WaitTask must pretty-print despite later id reuse: {out}"
);
assert!(
!out.contains("```\n{\"summary\"") && !out.contains("```\n{\"tasks\""),
"raw WaitTask JSON must never appear: {out}"
);
assert!(
out.contains("fn main()"),
"Read result must still render: {out}"
);
}
}