Skip to main content

txcript/harness/
simple.rs

1//! Simple — txcript's own interchange format, a pseudo-harness with no app
2//! behind it.
3//!
4//! The native format is a single JSON document: session metadata at the top
5//! level (all optional), and a `messages` array of `{role, content, …}`
6//! objects whose block shapes follow the Anthropic Messages API convention.
7//! `content` is a plain string or an array of `text` / `thinking` /
8//! `tool_use` / `tool_result` / `image` blocks. Almost everything is
9//! optional: missing tool ids are synthesized deterministically and id-less
10//! results pair FIFO with preceding unpaired calls; missing timestamps
11//! inherit the nearest preceding message's, then the session's. The format
12//! is specified level by level in `docs/formats/simple.md` — that document
13//! and this module are written to agree; when they drift, fix the document.
14//!
15//! Tolerance: a message that fails the typed parse, a block with an unknown
16//! `type`, and a message with an unknown role are preserved verbatim in the
17//! body ([`Record::Other`] or their raw fields) and excluded from the
18//! canonical conversation. Unknown keys at every level ride in flattened
19//! `extra` maps. Only a document that is not a JSON object with a `messages`
20//! array fails to parse.
21//!
22//! There is no [`Store`](crate::Store): a Simple session is a document
23//! handed to txcript directly (a file or stdin at the CLI, text at the WASM
24//! boundary), not something discovered from or written into a managed
25//! location — [`crate::local::write`] refuses the `simple` target
26//! accordingly. The codec itself stays symmetric so library and WASM users
27//! can render Simple text.
28//!
29//! Known losses: unknown keys and unmodeled records survive same-format
30//! round trips but do not cross [`Common`]; there is deliberately no
31//! system-prompt slot ([`Common`] has none, and every harness rebuilds its
32//! own environment on resume). Within the modeled fields the codec is a
33//! projection of [`Common`] itself, so `to_common(from_common(c)) == c`
34//! holds for any canonical transcript `c`.
35
36use 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/// The Simple harness marker.
48#[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/// The native body: the document's message entries plus any unknown
57/// top-level keys. The modeled top-level metadata (`id`, `timestamp`, `cwd`,
58/// `git_branch`, `title`, `cli_version`, `model`) lives in [`Meta`] and is
59/// re-rendered from it.
60#[derive(Debug, Clone, PartialEq, Default)]
61pub struct Doc {
62    pub records: Vec<Record>,
63    /// Unknown top-level keys, preserved through same-format round trips.
64    pub extra: Map<String, Value>,
65}
66
67/// One element of the `messages` array: a parsed message, or — when the
68/// typed parse fails (malformed timestamp, non-string-non-array content) —
69/// the element preserved verbatim.
70#[derive(Debug, Clone, PartialEq, Serialize)]
71#[serde(untagged)]
72pub enum Record {
73    Message(Entry),
74    Other(Value),
75}
76
77/// A message as written on the wire. Every field is optional but `role` and
78/// `content` are what make it conversation: an entry whose role is not
79/// `user`/`assistant` is preserved but contributes nothing to [`Common`].
80#[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    /// Unknown message-level keys, preserved.
95    #[serde(flatten)]
96    pub extra: Map<String, Value>,
97}
98
99/// Message content: the L0 plain string, or an array of blocks. Blocks stay
100/// raw [`Value`]s in the body — the codec interprets them, serde stays
101/// lossless.
102#[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    /// True for the default empty block array, so an absent `content` key
117    /// stays absent on render.
118    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
130// ── codec ──────────────────────────────────────────────────────────────
131
132impl Codec for Simple {
133    fn to_common(transcript: &Transcript<Self>) -> Result<Transcript<Common>> {
134        let mut messages = Vec::new();
135        // A message without a timestamp inherits the nearest preceding one.
136        let mut last_ts = transcript.meta.timestamp;
137        // Unpaired tool_use ids in emission order, for id-less results.
138        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                // Unknown roles are not conversation; they stay in the body.
147                _ => 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
185/// Interpret one raw block [`Value`]. `None` — an unknown `type` or a known
186/// type missing its defining field — drops the block from [`Common`]; it
187/// still lives in the body.
188fn 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                    // An explicitly paired call is no longer pending.
218                    if let Some(pos) = pending.iter().position(|p| *p == id) {
219                        pending.remove(pos);
220                    }
221                    id
222                }
223                // FIFO: the oldest unpaired call, per the Anthropic ordering
224                // convention; a result with no call at all still gets a
225                // stable synthetic id.
226                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
255/// Deterministic id for a `tool_use` without one (and for a `tool_result`
256/// with neither an id nor a pending call): pure function of the session id
257/// and the message/block index.
258fn 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        // The L0 shorthand: a single text block renders as a plain string.
266        [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
335// ── text codec ─────────────────────────────────────────────────────────
336
337impl 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        // The timestamp is extracted only when it parses; any other value
350        // stays in `extra` (and wins on render), so an odd document round
351        // trips rather than losing the field.
352        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        // Unknown top-level keys last: on the rare collision (a `timestamp`
403        // that never parsed), the preserved original wins.
404        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
413/// Parse one `messages` element; a failed typed parse preserves the element
414/// verbatim instead of erroring or dropping it.
415fn record(value: Value) -> Record {
416    match Entry::deserialize(&value) {
417        Ok(entry) => Record::Message(entry),
418        Err(_) => Record::Other(value),
419    }
420}
421
422/// Remove and return `key` only when it holds a string; other shapes stay in
423/// the map as preserved unknowns.
424fn 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}