#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
use chrono::{DateTime, Utc};
use proptest::prelude::*;
use proptest::test_runner::TestCaseError;
use serde_json::json;
use txcript::common::{Block, Message, Meta, Role, Tool, ToolOutput};
use txcript::harness::{
amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, fx, grok,
hermes, opencode, pi, simple,
};
use txcript::{Codec, Common, Transcript};
fn ts(secs: u32) -> DateTime<Utc> {
format!("2026-01-02T03:{:02}:{:02}.000Z", secs / 60, secs % 60)
.parse()
.unwrap()
}
#[derive(Debug, Clone)]
enum Unit {
UserText(String),
AssistantText {
thinking: Option<String>,
text: String,
},
ToolCall {
tool: Tool,
result: String,
},
}
fn arb_text() -> impl Strategy<Value = String> {
let line = "[a-zA-Z0-9]([a-zA-Z0-9 ,.!?<>&'/_-]{0,37}[a-zA-Z0-9,.!?<>&'/_-])?";
prop_oneof![
3 => line.prop_map(String::from),
1 => (line, line).prop_map(|(a, b)| format!("{a}\n{b}")),
]
}
fn arb_tool() -> impl Strategy<Value = Tool> {
prop_oneof![
(
"/repo/[a-z]{1,8}\\.rs",
arb_text(),
arb_text(),
any::<bool>()
)
.prop_map(
|(file_path, old_string, new_string, replace_all)| Tool::Edit {
file_path,
old_string,
new_string,
replace_all,
}
),
arb_text().prop_map(|command| Tool::Bash {
command,
workdir: None,
timeout_ms: None,
description: None,
run_in_background: false,
}),
("Custom_[a-z]{1,8}", arb_text()).prop_map(|(tool_name, note)| Tool::Raw {
tool_name,
input: json!({ "note": note }),
}),
]
}
fn arb_unit() -> impl Strategy<Value = Unit> {
prop_oneof![
arb_text().prop_map(Unit::UserText),
(proptest::option::of(arb_text()), arb_text())
.prop_map(|(thinking, text)| Unit::AssistantText { thinking, text }),
(arb_tool(), arb_text()).prop_map(|(tool, result)| Unit::ToolCall { tool, result }),
]
}
fn arb_conversation() -> impl Strategy<Value = Vec<Unit>> {
(arb_text(), proptest::collection::vec(arb_unit(), 0..6)).prop_map(|(opening, rest)| {
let mut units = vec![Unit::UserText(opening)];
units.extend(rest);
units
})
}
fn build(units: &[Unit]) -> Transcript<Common> {
let meta = Meta {
id: "prop-1".to_string(),
timestamp: ts(0),
cwd: Some("/repo".to_string()),
git_branch: None,
title: Some("property".to_string()),
cli_version: None,
model: Some("claude-opus-4-8".to_string()),
};
let msg = |role, content, secs| Message {
role,
content,
timestamp: ts(secs),
model: match role {
Role::Assistant => Some("claude-opus-4-8".to_string()),
Role::User => None,
},
stop_reason: None,
usage: None,
};
let mut body = Vec::new();
let mut calls = 0;
for (i, unit) in units.iter().enumerate() {
let secs = u32::try_from(i).unwrap() * 2 + 1;
match unit {
Unit::UserText(text) => {
body.push(msg(
Role::User,
vec![Block::Text { text: text.clone() }],
secs,
));
}
Unit::AssistantText { thinking, text } => {
let mut content = Vec::new();
if let Some(thinking) = thinking {
content.push(Block::Thinking {
text: thinking.clone(),
signature: None,
encrypted: None,
});
}
content.push(Block::Text { text: text.clone() });
body.push(msg(Role::Assistant, content, secs));
}
Unit::ToolCall { tool, result } => {
calls += 1;
let id = format!("call-{calls}");
body.push(msg(
Role::Assistant,
vec![Block::ToolUse {
id: id.clone(),
tool: tool.clone(),
}],
secs,
));
body.push(msg(
Role::User,
vec![Block::ToolResult {
tool_use_id: id,
content: ToolOutput::Text(result.clone()),
is_error: false,
}],
secs + 1,
));
}
}
}
Transcript::new(meta, body)
}
fn signature(t: &Transcript<Common>) -> Vec<String> {
let mut ids = std::collections::HashMap::new();
let mut renumber = |id: &str| {
let next = ids.len() + 1;
ids.entry(id.to_string()).or_insert(next).to_string()
};
let mut out = Vec::new();
for m in &t.body {
let role = match m.role {
Role::User => "user",
Role::Assistant => "assistant",
};
for block in &m.content {
let desc = match block {
Block::Text { text } => format!("text:{text}"),
Block::Thinking { text, .. } => format!("thinking:{text}"),
Block::ToolUse { id, tool } => {
let desc = match tool {
Tool::Edit {
file_path,
old_string,
new_string,
..
} => format!("Edit:{file_path}:{old_string}->{new_string}"),
other => format!("{other:?}"),
};
format!("use:{}:{desc}", renumber(id))
}
Block::ToolResult {
tool_use_id,
content,
is_error,
} => {
let text = match content {
ToolOutput::Text(s) => s.clone(),
ToolOutput::Json(v) => v.to_string(),
};
format!("result:{}:{is_error}:{text}", renumber(tool_use_id))
}
Block::Image { source } => format!("image:{}", source.media_type),
Block::Artifact { artifact } => {
format!("artifact:{}:{}", artifact.id, artifact.display_text())
}
};
out.push(format!("{role}/{desc}"));
}
}
out
}
fn assert_fixpoint<C: Codec>(name: &str, common: &Transcript<Common>) -> Result<(), TestCaseError> {
let native = C::from_common(common)
.map_err(|e| TestCaseError::fail(format!("{name}: from_common failed: {e}")))?;
let back = C::to_common(&native)
.map_err(|e| TestCaseError::fail(format!("{name}: to_common failed: {e}")))?;
prop_assert_eq!(
signature(common),
signature(&back),
"{} lost conversation",
name
);
Ok(())
}
proptest! {
#[test]
fn every_codec_preserves_generated_conversations(units in arb_conversation()) {
let common = build(&units);
assert_fixpoint::<claude_code::ClaudeCode>("claude_code", &common)?;
assert_fixpoint::<codex::Codex>("codex", &common)?;
assert_fixpoint::<opencode::OpenCode>("opencode", &common)?;
assert_fixpoint::<pi::Pi>("pi", &common)?;
assert_fixpoint::<campfire::Campfire>("campfire", &common)?;
assert_fixpoint::<cursor::Cursor>("cursor", &common)?;
assert_fixpoint::<cursor_desktop::CursorDesktop>("cursor_desktop", &common)?;
assert_fixpoint::<grok::Grok>("grok", &common)?;
assert_fixpoint::<fx::Fx>("fx", &common)?;
assert_fixpoint::<hermes::Hermes>("hermes", &common)?;
assert_fixpoint::<amp::Amp>("amp", &common)?;
assert_fixpoint::<antigravity::Antigravity>("antigravity", &common)?;
assert_fixpoint::<simple::Simple>("simple", &common)?;
assert_fixpoint::<cowork::Cowork>("cowork", &common)?;
}
}