use std::collections::HashMap;
use std::fmt::Write as _;
use crate::common::{Block, Message, Meta, Role, ToolOutput};
use crate::{Common, Span, Transcript};
#[must_use]
pub fn to_text(transcript: &Transcript<Common>) -> String {
let mut out = String::new();
header(&mut out, &transcript.meta);
let mut tool_ids = HashMap::<&str, usize>::new();
let mut next_tool_id = 1;
for message in &transcript.body {
blocks(&mut out, &mut tool_ids, &mut next_tool_id, message);
}
out
}
#[must_use]
pub fn to_text_fragment(transcript: &Transcript<Common>, span: &Span) -> Option<String> {
transcript.fragment(span).map(|messages| {
let mut out = String::new();
header(&mut out, &transcript.meta);
let total = transcript.body.len();
if span.0 != (0..total) {
field(&mut out, "fragment", &format_span(span));
field(&mut out, "of", &total.to_string());
}
let mut tool_ids = HashMap::<&str, usize>::new();
let mut next_tool_id = 1;
for (offset, message) in messages.iter().enumerate() {
let _ = write!(out, "\n── #{} ──", span.0.start + offset + 1);
blocks(&mut out, &mut tool_ids, &mut next_tool_id, message);
}
out
})
}
fn format_span(span: &Span) -> String {
match span.0.len() {
1 => format!("#{}", span.0.start + 1),
_ => format!("#{}-{}", span.0.start + 1, span.0.end),
}
}
fn header(out: &mut String, meta: &Meta) {
out.push_str("[session]\n");
field(out, "id", &meta.id);
field(out, "started", &meta.timestamp.to_rfc3339());
optional_field(out, "title", meta.title.as_deref());
optional_field(out, "cwd", meta.cwd.as_deref());
optional_field(out, "branch", meta.git_branch.as_deref());
optional_field(out, "model", meta.model.as_deref());
}
fn blocks<'a>(
out: &mut String,
tool_ids: &mut HashMap<&'a str, usize>,
next_tool_id: &mut usize,
message: &'a Message,
) {
for block in &message.content {
match block {
Block::Text { text } => {
let label = match message.role {
Role::User => "user",
Role::Assistant => "assistant",
};
section(out, label, text);
}
Block::Thinking { text, .. } => section(out, "thinking", text),
Block::ToolUse { id, tool } => {
let short_id = short_tool_id(tool_ids, next_tool_id, id);
let (name, input) = tool.to_canonical();
let body = match &input {
serde_json::Value::Null => String::new(),
serde_json::Value::Object(map) if map.is_empty() => String::new(),
input => input.to_string(),
};
section(out, &format!("tool {short_id} {}", one_line(&name)), &body);
}
Block::ToolResult {
tool_use_id,
content,
is_error,
} => {
let short_id = short_tool_id(tool_ids, next_tool_id, tool_use_id);
let error = if *is_error { " error" } else { "" };
let label = format!("result {short_id}{error}");
match content {
ToolOutput::Text(text) => section(out, &label, text),
ToolOutput::Json(value) => section(out, &label, &value.to_string()),
}
}
Block::Image { source } => section(
out,
&format!("image {} omitted", one_line(&source.media_type)),
"",
),
}
}
}
fn field(out: &mut String, name: &str, value: &str) {
let _ = writeln!(out, "{name}={}", one_line(value));
}
fn optional_field(out: &mut String, name: &str, value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
field(out, name, value);
}
}
fn one_line(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn section(out: &mut String, label: &str, text: &str) {
if !out.ends_with("\n\n") {
out.push('\n');
}
let _ = writeln!(out, "[{label}]");
out.push_str(text);
if !text.ends_with('\n') {
out.push('\n');
}
}
fn short_tool_id<'a>(
ids: &mut HashMap<&'a str, usize>,
next_id: &mut usize,
provider_id: &'a str,
) -> usize {
*ids.entry(provider_id).or_insert_with(|| {
let id = *next_id;
*next_id += 1;
id
})
}
#[cfg(test)]
mod tests {
use chrono::{DateTime, Utc};
use serde_json::json;
use super::*;
use crate::common::{ImageSource, Message, Meta, StopReason, Tool, Usage};
fn transcript(body: Vec<Message>) -> Transcript<Common> {
Transcript::new(
Meta {
id: "provider-session-id".into(),
timestamp: DateTime::<Utc>::UNIX_EPOCH,
cwd: Some("/work/project".into()),
git_branch: Some("main".into()),
title: Some("Fix the parser".into()),
cli_version: Some("9.9.9".into()),
model: Some("model-name".into()),
},
body,
)
}
fn message(role: Role, content: Vec<Block>) -> Message {
Message {
role,
content,
timestamp: DateTime::<Utc>::UNIX_EPOCH,
model: None,
stop_reason: Some(StopReason::EndTurn),
usage: Some(Usage {
input_tokens: 100,
output_tokens: 20,
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
}),
}
}
#[test]
fn renders_conversation_and_omits_replay_only_data() {
let rendered = to_text(&transcript(vec![
message(
Role::User,
vec![Block::Text {
text: "Please fix it.".into(),
}],
),
message(
Role::Assistant,
vec![
Block::Thinking {
text: "I should inspect the file.".into(),
signature: Some("large-signature".into()),
encrypted: Some("large-encrypted-payload".into()),
},
Block::Text {
text: "I found the issue.".into(),
},
Block::Image {
source: ImageSource {
source_type: "base64".into(),
media_type: "image/png".into(),
data: "large-base64-payload".into(),
},
},
],
),
]));
assert!(rendered.contains("[user]\nPlease fix it."));
assert!(rendered.contains("[thinking]\nI should inspect the file."));
assert!(rendered.contains("[assistant]\nI found the issue."));
assert!(rendered.contains("[image image/png omitted]"));
assert!(!rendered.contains("large-signature"));
assert!(!rendered.contains("large-encrypted-payload"));
assert!(!rendered.contains("large-base64-payload"));
assert!(!rendered.contains("input_tokens"));
assert!(!rendered.contains("cli_version"));
}
#[test]
fn compacts_tool_json_and_shortens_provider_ids() {
let rendered = to_text(&transcript(vec![
message(
Role::Assistant,
vec![Block::ToolUse {
id: "provider-generated-tool-id-with-many-tokens".into(),
tool: Tool::Read {
file_path: "src/lib.rs".into(),
offset: None,
limit: Some(20),
},
}],
),
message(
Role::User,
vec![Block::ToolResult {
tool_use_id: "provider-generated-tool-id-with-many-tokens".into(),
content: ToolOutput::Json(json!({"lines": ["one", "two"]})),
is_error: false,
}],
),
]));
assert!(rendered.contains("[tool 1 Read]\n{\"file_path\":\"src/lib.rs\",\"limit\":20}"));
assert!(rendered.contains("[result 1]\n{\"lines\":[\"one\",\"two\"]}"));
assert!(!rendered.contains("provider-generated-tool-id-with-many-tokens"));
}
#[test]
fn unmatched_results_still_receive_stable_short_ids() {
let rendered = to_text(&transcript(vec![message(
Role::User,
vec![
Block::ToolResult {
tool_use_id: "second".into(),
content: ToolOutput::Text("failed".into()),
is_error: true,
},
Block::ToolResult {
tool_use_id: "second".into(),
content: ToolOutput::Text("again".into()),
is_error: false,
},
],
)]));
assert!(rendered.contains("[result 1 error]\nfailed"));
assert!(rendered.contains("[result 1]\nagain"));
}
fn three_messages() -> Transcript<Common> {
transcript(vec![
message(
Role::User,
vec![Block::Text {
text: "first".into(),
}],
),
message(
Role::Assistant,
vec![Block::Text {
text: "second".into(),
}],
),
message(
Role::User,
vec![Block::Text {
text: "third".into(),
}],
),
])
}
#[test]
fn fragment_numbers_messages_with_full_session_ordinals() {
let full = to_text_fragment(&three_messages(), &Span(0..3)).unwrap();
assert!(full.contains("── #1 ──\n[user]\nfirst"));
assert!(full.contains("── #3 ──\n[user]\nthird"));
assert!(!full.contains("fragment="));
let partial = to_text_fragment(&three_messages(), &Span(1..3)).unwrap();
assert!(partial.contains("fragment=#2-3"));
assert!(partial.contains("of=3"));
assert!(partial.contains("── #2 ──\n[assistant]\nsecond"));
assert!(!partial.contains("first"));
let single = to_text_fragment(&three_messages(), &Span(1..2)).unwrap();
assert!(single.contains("fragment=#2\n"));
assert!(to_text_fragment(&three_messages(), &Span(1..4)).is_none());
}
#[test]
fn fragment_tool_ids_stay_paired_across_messages() {
let rendered = to_text_fragment(
&transcript(vec![
message(
Role::Assistant,
vec![Block::ToolUse {
id: "provider-a".into(),
tool: Tool::Raw {
tool_name: "Bash".into(),
input: json!({"command": "ls"}),
},
}],
),
message(
Role::User,
vec![Block::ToolResult {
tool_use_id: "provider-a".into(),
content: ToolOutput::Text("ok".into()),
is_error: false,
}],
),
]),
&Span(0..2),
)
.unwrap();
assert!(rendered.contains("[tool 1 Bash]"));
assert!(rendered.contains("[result 1]\nok"));
assert!(!rendered.contains("provider-a"));
}
}