Fold messages and parts into [`TurnData`]. The current turn starts at the last user message; reasoning, response, and step statistics are scoped to that window, while the transcript covers the whole session (mirroring how Claude Code's transcript file is a full-session record).
(messages: &[(String, String)], parts: &[(String, Value)])
| 234 | /// covers the whole session (mirroring how Claude Code's transcript file |
| 235 | /// is a full-session record). |
| 236 | fn assemble(messages: &[(String, String)], parts: &[(String, Value)]) -> TurnData { |
| 237 | let role_of: HashMap<&str, &str> = messages |
| 238 | .iter() |
| 239 | .map(|(id, role)| (id.as_str(), role.as_str())) |
| 240 | .collect(); |
| 241 | |
| 242 | // Turn window: message indices from the last user message onward. |
| 243 | let turn_start = messages |
| 244 | .iter() |
| 245 | .rposition(|(_, role)| role == "user") |
| 246 | .unwrap_or(0); |
| 247 | let in_turn: HashMap<&str, bool> = messages |
| 248 | .iter() |
| 249 | .enumerate() |
| 250 | .map(|(i, (id, _))| (id.as_str(), i >= turn_start)) |
| 251 | .collect(); |
| 252 | |
| 253 | let mut data = TurnData { |
| 254 | transcript_jsonl: String::new(), |
| 255 | reasoning_blocks: Vec::new(), |
| 256 | response: None, |
| 257 | input_tokens: 0, |
| 258 | output_tokens: 0, |
| 259 | reasoning_tokens: 0, |
| 260 | cache_read_tokens: 0, |
| 261 | cache_write_tokens: 0, |
| 262 | cost_usd: 0.0, |
| 263 | finish_reason: None, |
| 264 | step_count: 0, |
| 265 | tool_parts: Vec::new(), |
| 266 | }; |
| 267 | |
| 268 | let push_line = |line: Value, out: &mut TurnData| { |
| 269 | if let Ok(s) = serde_json::to_string(&line) { |
| 270 | out.transcript_jsonl.push_str(&s); |
| 271 | out.transcript_jsonl.push('\n'); |
| 272 | } |
| 273 | }; |
| 274 | |
| 275 | for (message_id, part) in parts { |
| 276 | let part_type = part.get("type").and_then(Value::as_str).unwrap_or(""); |
| 277 | let in_turn = in_turn.get(message_id.as_str()).copied().unwrap_or(false); |
| 278 | let role = role_of |
| 279 | .get(message_id.as_str()) |
| 280 | .copied() |
| 281 | .unwrap_or("assistant"); |
| 282 | |
| 283 | match part_type { |
| 284 | "text" => { |
| 285 | let Some(text) = part.get("text").and_then(Value::as_str) else { |
| 286 | continue; |
| 287 | }; |
| 288 | if text.trim().is_empty() { |
| 289 | continue; |
| 290 | } |
| 291 | let line = serde_json::json!({ "type": role, "text": text }); |
| 292 | push_line(line, &mut data); |
| 293 | if in_turn && role == "assistant" { |