1use std::collections::VecDeque;
37
38use chrono::{DateTime, SecondsFormat, Utc};
39use serde::{Deserialize, Serialize};
40use serde_json::{Map, Value, json};
41use uuid::Uuid;
42
43use crate::common::{Block, ImageSource, Message, Meta, Role, StopReason, Tool, ToolOutput, Usage};
44use crate::error::{Error, Result};
45use crate::transcript::{Codec, Common, Harness, TextCodec, Transcript};
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct Simple;
50
51impl Harness for Simple {
52 const NAME: &'static str = "simple";
53 type Body = Doc;
54}
55
56#[derive(Debug, Clone, PartialEq, Default)]
61pub struct Doc {
62 pub records: Vec<Record>,
63 pub extra: Map<String, Value>,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize)]
71#[serde(untagged)]
72pub enum Record {
73 Message(Entry),
74 Other(Value),
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct Entry {
82 #[serde(default, skip_serializing_if = "String::is_empty")]
83 pub role: String,
84 #[serde(default, skip_serializing_if = "Content::is_empty_blocks")]
85 pub content: Content,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub timestamp: Option<DateTime<Utc>>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub model: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub stop_reason: Option<StopReason>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub usage: Option<Usage>,
94 #[serde(flatten)]
96 pub extra: Map<String, Value>,
97}
98
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103#[serde(untagged)]
104pub enum Content {
105 Text(String),
106 Blocks(Vec<Value>),
107}
108
109impl Default for Content {
110 fn default() -> Self {
111 Content::Blocks(Vec::new())
112 }
113}
114
115impl Content {
116 fn is_empty_blocks(&self) -> bool {
119 matches!(self, Content::Blocks(blocks) if blocks.is_empty())
120 }
121}
122
123fn malformed(detail: impl Into<String>) -> Error {
124 Error::Malformed {
125 harness: Simple::NAME,
126 detail: detail.into(),
127 }
128}
129
130impl Codec for Simple {
133 fn to_common(transcript: &Transcript<Self>) -> Result<Transcript<Common>> {
134 let mut messages = Vec::new();
135 let mut last_ts = transcript.meta.timestamp;
137 let mut pending: VecDeque<String> = VecDeque::new();
139 for (i, record) in transcript.body.records.iter().enumerate() {
140 let Record::Message(entry) = record else {
141 continue;
142 };
143 let role = match entry.role.trim().to_ascii_lowercase().as_str() {
144 "user" => Role::User,
145 "assistant" => Role::Assistant,
146 _ => continue,
148 };
149 let timestamp = entry.timestamp.unwrap_or(last_ts);
150 last_ts = timestamp;
151 let content = match &entry.content {
152 Content::Text(text) => vec![Block::Text { text: text.clone() }],
153 Content::Blocks(blocks) => blocks
154 .iter()
155 .enumerate()
156 .filter_map(|(j, block)| {
157 block_to_common(block, &transcript.meta.id, i, j, &mut pending)
158 })
159 .collect(),
160 };
161 messages.push(Message {
162 role,
163 content,
164 timestamp,
165 model: entry.model.clone(),
166 stop_reason: entry.stop_reason.clone(),
167 usage: entry.usage,
168 });
169 }
170 Ok(Transcript::new(transcript.meta.clone(), messages))
171 }
172
173 fn from_common(transcript: &Transcript<Common>) -> Result<Transcript<Self>> {
174 let records = transcript.body.iter().map(entry_from_common).collect();
175 Ok(Transcript::new(
176 transcript.meta.clone(),
177 Doc {
178 records,
179 extra: Map::new(),
180 },
181 ))
182 }
183}
184
185fn block_to_common(
189 block: &Value,
190 session_id: &str,
191 i: usize,
192 j: usize,
193 pending: &mut VecDeque<String>,
194) -> Option<Block> {
195 match block.get("type").and_then(Value::as_str)? {
196 "text" => Some(Block::Text {
197 text: block.get("text")?.as_str()?.to_string(),
198 }),
199 "thinking" => Some(Block::Thinking {
200 text: block.get("text")?.as_str()?.to_string(),
201 signature: str_field(block, "signature"),
202 encrypted: str_field(block, "encrypted"),
203 }),
204 "tool_use" => {
205 let name = block.get("name")?.as_str()?;
206 let input = block.get("input").cloned().unwrap_or(Value::Null);
207 let id = str_field(block, "id").unwrap_or_else(|| synth_id(session_id, i, j));
208 pending.push_back(id.clone());
209 Some(Block::ToolUse {
210 id,
211 tool: Tool::from_canonical(name, input),
212 })
213 }
214 "tool_result" => {
215 let tool_use_id = match str_field(block, "tool_use_id") {
216 Some(id) => {
217 if let Some(pos) = pending.iter().position(|p| *p == id) {
219 pending.remove(pos);
220 }
221 id
222 }
223 None => pending
227 .pop_front()
228 .unwrap_or_else(|| synth_id(session_id, i, j)),
229 };
230 let content = match block.get("content") {
231 None => ToolOutput::Text(String::new()),
232 Some(Value::String(text)) => ToolOutput::Text(text.clone()),
233 Some(other) => ToolOutput::Json(other.clone()),
234 };
235 Some(Block::ToolResult {
236 tool_use_id,
237 content,
238 is_error: block
239 .get("is_error")
240 .and_then(Value::as_bool)
241 .unwrap_or(false),
242 })
243 }
244 "image" => ImageSource::deserialize(block.get("source")?)
245 .ok()
246 .map(|source| Block::Image { source }),
247 _ => None,
248 }
249}
250
251fn str_field(value: &Value, key: &str) -> Option<String> {
252 value.get(key).and_then(Value::as_str).map(String::from)
253}
254
255fn synth_id(session_id: &str, i: usize, j: usize) -> String {
259 const NS: Uuid = Uuid::from_bytes(*b"txcript-simple!!");
260 Uuid::new_v5(&NS, format!("{session_id}:{i}:{j}").as_bytes()).to_string()
261}
262
263fn entry_from_common(message: &Message) -> Record {
264 let content = match message.content.as_slice() {
265 [Block::Text { text }] => Content::Text(text.clone()),
267 blocks => Content::Blocks(blocks.iter().map(block_to_value).collect()),
268 };
269 Record::Message(Entry {
270 role: match message.role {
271 Role::User => "user",
272 Role::Assistant => "assistant",
273 }
274 .to_string(),
275 content,
276 timestamp: Some(message.timestamp),
277 model: message.model.clone(),
278 stop_reason: message.stop_reason.clone(),
279 usage: message.usage,
280 extra: Map::new(),
281 })
282}
283
284fn block_to_value(block: &Block) -> Value {
285 match block {
286 Block::Text { text } => json!({"type": "text", "text": text}),
287 Block::Thinking {
288 text,
289 signature,
290 encrypted,
291 } => {
292 let mut map = Map::new();
293 map.insert("type".into(), json!("thinking"));
294 map.insert("text".into(), json!(text));
295 if let Some(signature) = signature {
296 map.insert("signature".into(), json!(signature));
297 }
298 if let Some(encrypted) = encrypted {
299 map.insert("encrypted".into(), json!(encrypted));
300 }
301 Value::Object(map)
302 }
303 Block::ToolUse { id, tool } => {
304 let (name, input) = tool.to_canonical();
305 json!({"type": "tool_use", "id": id, "name": name, "input": input})
306 }
307 Block::ToolResult {
308 tool_use_id,
309 content,
310 is_error,
311 } => {
312 let mut map = Map::new();
313 map.insert("type".into(), json!("tool_result"));
314 map.insert("tool_use_id".into(), json!(tool_use_id));
315 map.insert(
316 "content".into(),
317 match content {
318 ToolOutput::Text(text) => Value::String(text.clone()),
319 ToolOutput::Json(value) => value.clone(),
320 },
321 );
322 if *is_error {
323 map.insert("is_error".into(), Value::Bool(true));
324 }
325 Value::Object(map)
326 }
327 Block::Image { source } => json!({"type": "image", "source": {
328 "type": source.source_type,
329 "media_type": source.media_type,
330 "data": source.data,
331 }}),
332 }
333}
334
335impl TextCodec for Simple {
338 fn from_text(text: &str) -> Result<Transcript<Self>> {
339 let document: Value = serde_json::from_str(text)?;
340 let Value::Object(mut map) = document else {
341 return Err(malformed("top level is not a JSON object"));
342 };
343 let messages = match map.remove("messages") {
344 Some(Value::Array(messages)) => messages,
345 Some(_) => return Err(malformed("`messages` is not an array")),
346 None => return Err(malformed("no `messages` array")),
347 };
348 let records = messages.into_iter().map(record).collect();
349 let timestamp = map
353 .get("timestamp")
354 .and_then(Value::as_str)
355 .and_then(|s| s.parse::<DateTime<Utc>>().ok());
356 if timestamp.is_some() {
357 map.remove("timestamp");
358 }
359 let meta = Meta {
360 id: take_str(&mut map, "id").unwrap_or_default(),
361 timestamp: timestamp.unwrap_or_else(Utc::now),
362 cwd: take_str(&mut map, "cwd"),
363 git_branch: take_str(&mut map, "git_branch"),
364 title: take_str(&mut map, "title"),
365 cli_version: take_str(&mut map, "cli_version"),
366 model: take_str(&mut map, "model"),
367 };
368 Ok(Transcript::new(
369 meta,
370 Doc {
371 records,
372 extra: map,
373 },
374 ))
375 }
376
377 fn to_text(transcript: &Transcript<Self>) -> Result<String> {
378 let meta = &transcript.meta;
379 let mut map = Map::new();
380 if !meta.id.is_empty() {
381 map.insert("id".into(), json!(meta.id));
382 }
383 map.insert(
384 "timestamp".into(),
385 json!(meta.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)),
386 );
387 for (key, value) in [
388 ("cwd", &meta.cwd),
389 ("git_branch", &meta.git_branch),
390 ("title", &meta.title),
391 ("cli_version", &meta.cli_version),
392 ("model", &meta.model),
393 ] {
394 if let Some(value) = value {
395 map.insert(key.into(), json!(value));
396 }
397 }
398 map.insert(
399 "messages".into(),
400 serde_json::to_value(&transcript.body.records)?,
401 );
402 for (key, value) in &transcript.body.extra {
405 map.insert(key.clone(), value.clone());
406 }
407 let mut out = serde_json::to_string_pretty(&Value::Object(map))?;
408 out.push('\n');
409 Ok(out)
410 }
411}
412
413fn record(value: Value) -> Record {
416 match Entry::deserialize(&value) {
417 Ok(entry) => Record::Message(entry),
418 Err(_) => Record::Other(value),
419 }
420}
421
422fn take_str(map: &mut Map<String, Value>, key: &str) -> Option<String> {
425 if !map.get(key).is_some_and(Value::is_string) {
426 return None;
427 }
428 match map.remove(key) {
429 Some(Value::String(s)) => Some(s),
430 _ => None,
431 }
432}