#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
use chrono::{DateTime, Utc};
use serde_json::json;
use txcript::harness::opencode::Export;
use txcript::{
Block, Codec, Common, Message, Meta, OpenCode, Role, StopReason, Tool, ToolOutput, Transcript,
Usage,
};
fn ts(s: &str) -> DateTime<Utc> {
s.parse().unwrap()
}
fn meta() -> Meta {
Meta {
id: "ses_1".into(),
timestamp: ts("2026-01-02T03:04:05.000Z"),
cwd: Some("/repo".into()),
git_branch: None,
title: Some("Demo".into()),
cli_version: Some("1.15.0".into()),
model: Some("claude-opus-4-7".into()),
}
}
#[test]
fn to_common_splits_parts_and_attaches_turn_usage() {
let export: Export = serde_json::from_value(json!({
"info": { "id": "ses_1", "directory": "/repo" },
"messages": [
{
"info": { "role": "user", "time": { "created": 1778834704520i64 } },
"parts": [{ "type": "text", "text": "please edit the file" }],
},
{
"info": {
"role": "assistant",
"modelID": "claude-opus-4-7",
"finish": "stop",
"tokens": { "input": 6, "output": 88, "cache": { "write": 21428, "read": 10 } },
"time": { "created": 1778834704540i64 },
},
"parts": [
{ "type": "step-start", "snapshot": "abc" },
{ "type": "reasoning", "text": "thinking about it" },
{ "type": "text", "text": "On it." },
{ "type": "tool", "tool": "edit", "callID": "call-1", "state": {
"status": "completed",
"input": { "filePath": "/repo/a.rs", "oldString": "old", "newString": "new" },
"output": "done" } },
{ "type": "text", "text": "Finished the edit." },
{ "type": "step-finish", "reason": "stop" },
],
},
],
}))
.unwrap();
let common = OpenCode::to_common(&Transcript::new(meta(), export)).unwrap();
let msgs = &common.body;
assert_eq!(msgs.len(), 5);
assert!(matches!(&msgs[0].content[0], Block::Text { text } if text == "please edit the file"));
assert!(
matches!(&msgs[1].content[0], Block::Thinking { text, .. } if text == "thinking about it")
);
assert!(matches!(&msgs[1].content[1], Block::Text { text } if text == "On it."));
assert!(matches!(
&msgs[2].content[0],
Block::ToolUse { id, tool: Tool::Edit { file_path, old_string, new_string, .. } }
if id == "call-1" && file_path == "/repo/a.rs" && old_string == "old" && new_string == "new"
));
assert!(matches!(
&msgs[3].content[0],
Block::ToolResult { tool_use_id, content: ToolOutput::Text(t), is_error: false }
if tool_use_id == "call-1" && t == "done"
));
let last = &msgs[4];
assert!(matches!(&last.content[0], Block::Text { text } if text == "Finished the edit."));
assert_eq!(last.stop_reason, Some(StopReason::EndTurn));
let usage = last.usage.unwrap();
assert_eq!(usage.input_tokens, 6);
assert_eq!(usage.output_tokens, 88);
assert_eq!(usage.cache_creation_input_tokens, Some(21428));
assert_eq!(usage.cache_read_input_tokens, Some(10));
}
#[test]
fn errored_tool_call_becomes_error_result() {
let export: Export = serde_json::from_value(json!({
"info": { "id": "ses_1" },
"messages": [{
"info": { "role": "assistant", "modelID": "m", "time": { "created": 1i64 } },
"parts": [{ "type": "tool", "tool": "bash", "callID": "c9", "state": {
"status": "error", "input": { "command": "false" }, "error": "exit code 1" } }],
}],
}))
.unwrap();
let msgs = OpenCode::to_common(&Transcript::new(meta(), export))
.unwrap()
.body;
assert_eq!(msgs.len(), 2);
assert!(matches!(
&msgs[0].content[0],
Block::ToolUse {
tool: Tool::Bash { .. },
..
}
));
assert!(matches!(
&msgs[1].content[0],
Block::ToolResult { content: ToolOutput::Text(t), is_error: true, .. } if t == "exit code 1"
));
}
#[test]
fn pending_tool_call_has_no_result() {
let export: Export = serde_json::from_value(json!({
"info": { "id": "ses_1" },
"messages": [{
"info": { "role": "assistant", "modelID": "m", "time": { "created": 1i64 } },
"parts": [{ "type": "tool", "tool": "read", "callID": "c5", "state": {
"status": "running", "input": { "filePath": "/repo/a.rs" } } }],
}],
}))
.unwrap();
let msgs = OpenCode::to_common(&Transcript::new(meta(), export))
.unwrap()
.body;
assert_eq!(msgs.len(), 1);
assert!(matches!(
&msgs[0].content[0],
Block::ToolUse {
tool: Tool::Read { .. },
..
}
));
}
fn sample_common() -> Transcript<Common> {
let model = || Some("claude-opus-4-7".to_string());
let body = vec![
Message {
role: Role::User,
content: vec![Block::Text {
text: "edit it".into(),
}],
timestamp: ts("2026-01-02T03:04:06.000Z"),
model: None,
stop_reason: None,
usage: None,
},
Message {
role: Role::Assistant,
content: vec![
Block::Thinking {
text: "thinking".into(),
signature: None,
encrypted: None,
},
Block::Text {
text: "on it".into(),
},
],
timestamp: ts("2026-01-02T03:04:07.000Z"),
model: model(),
stop_reason: Some(StopReason::EndTurn),
usage: Some(Usage {
input_tokens: 6,
output_tokens: 88,
cache_read_input_tokens: Some(10),
cache_creation_input_tokens: Some(21428),
}),
},
Message {
role: Role::Assistant,
content: vec![Block::ToolUse {
id: "call-1".into(),
tool: Tool::Edit {
file_path: "/repo/a.rs".into(),
old_string: "old".into(),
new_string: "new".into(),
replace_all: false,
},
}],
timestamp: ts("2026-01-02T03:04:08.000Z"),
model: model(),
stop_reason: Some(StopReason::ToolUse),
usage: None,
},
Message {
role: Role::User,
content: vec![Block::ToolResult {
tool_use_id: "call-1".into(),
content: ToolOutput::Text("done".into()),
is_error: false,
}],
timestamp: ts("2026-01-02T03:04:08.000Z"),
model: None,
stop_reason: None,
usage: None,
},
Message {
role: Role::Assistant,
content: vec![Block::Text {
text: "finished".into(),
}],
timestamp: ts("2026-01-02T03:04:10.000Z"),
model: model(),
stop_reason: Some(StopReason::EndTurn),
usage: Some(Usage {
input_tokens: 1,
output_tokens: 2,
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
}),
},
];
Transcript::new(meta(), body)
}
#[test]
fn codec_fixpoint_through_common_loses_nothing() {
let common = sample_common();
let native = OpenCode::from_common(&common).unwrap();
let back = OpenCode::to_common(&native).unwrap();
assert_eq!(common, back);
}