//! normalize chat_history.jsonl, convert any v1 (ConversationItem) to v0 (ChatRequestMessage) format. //! Used for data processing pipeline. //! //! Usage: //! chat-history-downgrade //! //! ## Reasoning-shape compatibility //! //! Two on-disk v1 shapes carry reasoning, both must be downgraded into //! the v0 `reasoning_content: Option` field: //! //! 1. **Legacy shape** -- reasoning lived as a field on the //! assistant item itself: `{"type":"assistant","reasoning":{"text":...},...}`. //! Post-refactor `AssistantItem` no longer has that field, so serde //! silently drops it on deserialize. We pre-extract it from the raw //! JSON before parsing as `ConversationItem`. //! //! 2. **Current shape** -- reasoning is a sibling //! `ConversationItem::Reasoning(rs::ReasoningItem)` item that precedes //! the assistant in the JSONL stream. The downgrade buffers these //! sibling lines and folds their text into the next assistant's //! `reasoning_content`, matching what `conversation_to_chat_messages` //! does at the in-process layer. Intervening user / tool messages //! clear the buffer. use std::fs::File; use std::io::{BufRead, BufReader, BufWriter, Write}; use std::path::PathBuf; use clap::Parser; use kigi_shell::sampling::conversation::{ConversationItem, conversation_item_to_chat_message}; use kigi_shell::sampling::types::{ChatRequestMessage, Role}; /// normalize chat_history.jsonl, convert any v1 (ConversationItem) to v0 (ChatRequestMessage) format. #[derive(Parser)] #[command(name = "chat-history-downgrade")] struct Args { /// Input v1 chat_history.jsonl file input: PathBuf, /// Output v0 chat_history.jsonl file output: PathBuf, } /// Convert one v1 JSONL line to a v0 `ChatRequestMessage`, threading /// `pending_reasoning` across calls so sibling `Reasoning` items are /// folded into the following assistant. /// /// Returns: /// - `Ok(Some(msg))` -- emit this v0 message. /// - `Ok(None)` -- line was a sibling `Reasoning` item; its text has been /// buffered into `pending_reasoning` for the next assistant. Skip emit. /// - `Err(_)` -- line was neither v1 nor v0 parseable. fn convert_line( trimmed: &str, pending_reasoning: &mut Vec, ) -> anyhow::Result> { // Inspect the raw JSON first so we can: // (a) extract a legacy `assistant.reasoning.text` field before // strongly-typed parsing drops it, and // (b) buffer sibling `Reasoning` lines. let raw: serde_json::Value = serde_json::from_str(trimmed) .map_err(|e| anyhow::anyhow!("line is not valid JSON: {e}"))?; let item_type = raw.get("type").and_then(|t| t.as_str()); // (b) Sibling Reasoning: extract text and buffer for the next assistant. if item_type == Some("reasoning") { if let Some(text) = extract_reasoning_text(&raw) { pending_reasoning.push(text); } return Ok(None); } // (a) Legacy reasoning field on the assistant item. // Tries `reasoning.text` first (chat-completions-style) then // `reasoning.encrypted` (responses-API-style); the latter is opaque // bytes so we surface it as a placeholder rather than dropping it // silently. Real text wins if both are present. let legacy_reasoning: Option = if item_type == Some("assistant") { raw.get("reasoning").and_then(|r| { r.get("text") .and_then(|t| t.as_str()) .map(String::from) .or_else(|| { r.get("encrypted") .and_then(|t| t.as_str()) .map(|_| "[encrypted reasoning]".to_string()) }) }) } else { None }; // Parse as v1, fall back to v0 passthrough. let mut chat_msg: ChatRequestMessage = match serde_json::from_value::(raw.clone()) { Ok(item) => conversation_item_to_chat_message(item), Err(v1_err) => serde_json::from_value::(raw) .map_err(|_| anyhow::anyhow!("failed to parse as v1 or v0: {v1_err}"))?, }; // Attach reasoning to assistant messages, with the legacy field // taking precedence when both sources exist. if let Some(text) = legacy_reasoning { chat_msg.reasoning_content = Some(text); pending_reasoning.clear(); } else if matches!(chat_msg.role, Role::Assistant) && !pending_reasoning.is_empty() { chat_msg.reasoning_content = Some(pending_reasoning.join("\n")); pending_reasoning.clear(); } else if !matches!(chat_msg.role, Role::Assistant) { // Intervening user / tool message clears pending reasoning -- // matches `conversation_to_chat_messages` (attaches to the // immediately-following assistant only). pending_reasoning.clear(); } Ok(Some(chat_msg)) } /// Extract joined reasoning text from a sibling `Reasoning` JSON value. /// Joins `summary[].text` and `content[].text` blocks. fn extract_reasoning_text(raw: &serde_json::Value) -> Option { let mut parts: Vec = Vec::new(); if let Some(summary) = raw.get("summary").and_then(|s| s.as_array()) { for sp in summary { if let Some(t) = sp.get("text").and_then(|t| t.as_str()) && !t.is_empty() { parts.push(t.to_string()); } } } if let Some(content) = raw.get("content").and_then(|c| c.as_array()) { for cp in content { if let Some(t) = cp.get("text").and_then(|t| t.as_str()) && !t.is_empty() { parts.push(t.to_string()); } } } if parts.is_empty() { None } else { Some(parts.join("\n")) } } fn main() -> anyhow::Result<()> { let args = Args::parse(); let reader = BufReader::new(File::open(&args.input)?); let mut writer = BufWriter::new(File::create(&args.output)?); let mut converted = 0usize; let mut pending_reasoning: Vec = Vec::new(); for line in reader.lines() { let line = line?; let trimmed = line.trim(); if trimmed.is_empty() { continue; } let Some(chat_msg) = convert_line(trimmed, &mut pending_reasoning)? else { // Sibling Reasoning item: buffered, no v0 line to emit. continue; }; serde_json::to_writer(&mut writer, &chat_msg)?; writer.write_all(b"\n")?; converted += 1; } writer.flush()?; eprintln!("Done: {converted} messages converted"); Ok(()) } // ============================================================================ // Tests — hardcoded JSON fixtures so schema changes in either // ConversationItem (v1) or ChatRequestMessage (v0) will break these. // ============================================================================ #[cfg(test)] mod tests { use super::*; /// Parse a single v1 JSON line, convert to v0, re-serialize, and /// re-parse as v0. Returns the v0 JSON string for further assertions. /// Uses a fresh empty `pending_reasoning` buffer so single-line tests /// stay self-contained. fn convert_line_for_test(v1_json: &str) -> String { let mut pending = Vec::new(); let v0 = convert_line(v1_json, &mut pending) .expect("convert_line should succeed") .expect("v1 line should produce a v0 message (not a buffered Reasoning)"); let out = serde_json::to_string(&v0).expect("v0 should serialize"); // Verify the output is valid v0 let _: ChatRequestMessage = serde_json::from_str(&out).expect("v0 output should round-trip"); out } fn v0_value(json: &str) -> serde_json::Value { serde_json::from_str(json).unwrap() } #[test] fn test_system_message() { let v1 = r#"{"type":"system","content":"You are a helpful assistant."}"#; let out = convert_line_for_test(v1); let v = v0_value(&out); assert_eq!(v["role"], "system"); assert_eq!(v["content"], "You are a helpful assistant."); } #[test] fn test_user_text_message() { let v1 = r#"{"type":"user","content":[{"type":"text","text":"Hello!"}]}"#; let out = convert_line_for_test(v1); let v = v0_value(&out); assert_eq!(v["role"], "user"); // v0 content must be a plain string, not an array of blocks assert_eq!(v["content"], "Hello!"); } #[test] fn test_user_with_image() { let v1 = r#"{"type":"user","content":[{"type":"text","text":"Look at this"},{"type":"image","url":"https://example.com/img.png"}]}"#; let out = convert_line_for_test(v1); let v = v0_value(&out); assert_eq!(v["role"], "user"); let blocks = v["content"].as_array().expect("content should be array"); assert_eq!(blocks.len(), 2); assert_eq!(blocks[1]["image_url"]["url"], "https://example.com/img.png"); } #[test] fn test_assistant_simple() { let v1 = r#"{"type":"assistant","content":"Hi there!","tool_calls":[]}"#; let out = convert_line_for_test(v1); let v = v0_value(&out); assert_eq!(v["role"], "assistant"); assert_eq!(v["content"], "Hi there!"); } /// Legacy shape: `reasoning` was a field on the /// assistant item. Post-refactor the field doesn't exist on /// `AssistantItem`, so serde would silently drop it. We pre-extract /// it from the raw JSON to preserve downstream data fidelity. #[test] fn test_assistant_with_reasoning() { let v1 = r#"{"type":"assistant","content":"The answer is 42.","reasoning":{"text":"Let me think..."},"tool_calls":[],"model_id":"kigi-3"}"#; let out = convert_line_for_test(v1); let v = v0_value(&out); assert_eq!(v["role"], "assistant"); assert_eq!(v["content"], "The answer is 42."); assert_eq!(v["reasoning_content"], "Let me think..."); assert_eq!(v["model_id"], "kigi-3"); } /// Current shape: reasoning is a sibling line /// before the assistant. The downgrade buffers it and folds the /// text into the next assistant's `reasoning_content`. #[test] fn test_sibling_reasoning_folds_into_following_assistant() { let mut pending = Vec::new(); let r_line = r#"{"type":"reasoning","id":"rs_abc","summary":[{"type":"summary_text","text":"Let me think..."}]}"#; let r = convert_line(r_line, &mut pending).unwrap(); assert!( r.is_none(), "sibling Reasoning line must be buffered, not emitted" ); assert_eq!(pending.len(), 1); let a_line = r#"{"type":"assistant","content":"The answer is 42.","tool_calls":[],"model_id":"kigi-3"}"#; let a = convert_line(a_line, &mut pending) .unwrap() .expect("assistant line produces a v0 message"); let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); assert_eq!(v["role"], "assistant"); assert_eq!(v["content"], "The answer is 42."); assert_eq!(v["reasoning_content"], "Let me think..."); assert!(pending.is_empty(), "buffer must be flushed after attaching"); } /// Multiple sibling Reasoning lines before one assistant get joined /// with newlines (matches `conversation_to_chat_messages`). #[test] fn test_multiple_sibling_reasoning_joined_into_one_assistant() { let mut pending = Vec::new(); for (id, text) in &[("rs_1", "first"), ("rs_2", "second"), ("rs_3", "third")] { let r_line = format!( r#"{{"type":"reasoning","id":"{id}","summary":[{{"type":"summary_text","text":"{text}"}}]}}"# ); let r = convert_line(&r_line, &mut pending).unwrap(); assert!(r.is_none()); } assert_eq!(pending.len(), 3); let a_line = r#"{"type":"assistant","content":"ok","tool_calls":[]}"#; let a = convert_line(a_line, &mut pending).unwrap().unwrap(); let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); assert_eq!(v["reasoning_content"], "first\nsecond\nthird"); assert!(pending.is_empty()); } /// An intervening user / tool message clears pending reasoning -- /// matches the `conversation_to_chat_messages` semantic where /// reasoning attaches only to the immediately-following assistant. #[test] fn test_intervening_user_clears_pending_reasoning() { let mut pending = Vec::new(); let r_line = r#"{"type":"reasoning","id":"rs_orphan","summary":[{"type":"summary_text","text":"orphan"}]}"#; convert_line(r_line, &mut pending).unwrap(); assert_eq!(pending.len(), 1); let u_line = r#"{"type":"user","content":[{"type":"text","text":"new turn"}]}"#; convert_line(u_line, &mut pending).unwrap(); assert!( pending.is_empty(), "user message must clear pending reasoning" ); let a_line = r#"{"type":"assistant","content":"ok","tool_calls":[]}"#; let a = convert_line(a_line, &mut pending).unwrap().unwrap(); let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); assert!( v.get("reasoning_content").is_none() || v["reasoning_content"].is_null(), "orphan reasoning must not attach to assistant across a user turn" ); } /// Legacy assistant.reasoning field wins over buffered sibling /// reasoning -- the explicit per-assistant field is the more /// specific signal. #[test] fn test_legacy_field_overrides_buffered_sibling() { let mut pending = Vec::new(); let r_line = r#"{"type":"reasoning","id":"rs_sib","summary":[{"type":"summary_text","text":"from sibling"}]}"#; convert_line(r_line, &mut pending).unwrap(); let a_line = r#"{"type":"assistant","content":"ok","reasoning":{"text":"from legacy field"},"tool_calls":[]}"#; let a = convert_line(a_line, &mut pending).unwrap().unwrap(); let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); assert_eq!(v["reasoning_content"], "from legacy field"); assert!(pending.is_empty()); } #[test] fn test_assistant_with_tool_calls() { let v1 = r#"{"type":"assistant","content":"","tool_calls":[{"id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}]}"#; let out = convert_line_for_test(v1); let v = v0_value(&out); assert_eq!(v["role"], "assistant"); let tc = &v["tool_calls"][0]; assert_eq!(tc["id"], "call_1"); assert_eq!(tc["function"]["name"], "bash"); assert_eq!(tc["function"]["arguments"], r#"{"command":"ls"}"#); } #[test] fn test_tool_result() { let v1 = r#"{"type":"tool_result","tool_call_id":"call_1","content":"file1.txt\nfile2.txt"}"#; let out = convert_line_for_test(v1); let v = v0_value(&out); assert_eq!(v["role"], "tool"); assert_eq!(v["tool_call_id"], "call_1"); assert_eq!(v["content"], "file1.txt\nfile2.txt"); } #[test] fn test_v0_passthrough() { // Already v0 — should pass through without error let v0_input = r#"{"role":"system","content":"Hello"}"#; let parsed: ChatRequestMessage = serde_json::from_str(v0_input).expect("v0 fixture should parse as ChatRequestMessage"); let out = serde_json::to_string(&parsed).unwrap(); let v = v0_value(&out); assert_eq!(v["role"], "system"); } #[test] fn test_invalid_json_fails() { let bad = r#"{"type":"unknown_variant","foo":"bar"}"#; let v1_result = serde_json::from_str::(bad); let v0_result = serde_json::from_str::(bad); assert!(v1_result.is_err()); assert!(v0_result.is_err()); } #[test] fn test_full_file_roundtrip() { let v1_lines = [ r#"{"type":"system","content":"System prompt"}"#, r#"{"type":"user","content":[{"type":"text","text":"Hello"}]}"#, r#"{"type":"assistant","content":"Hi!","tool_calls":[]}"#, r#"{"type":"assistant","content":"","tool_calls":[{"id":"c1","name":"bash","arguments":"{}"}]}"#, r#"{"type":"tool_result","tool_call_id":"c1","content":"ok"}"#, ]; let dir = tempfile::tempdir().unwrap(); let input_path = dir.path().join("input.jsonl"); let output_path = dir.path().join("output.jsonl"); // Write v1 input std::fs::write(&input_path, v1_lines.join("\n") + "\n").unwrap(); // Run the conversion logic using the same `convert_line` the // binary's main loop uses, so the test exercises the real path. let reader = BufReader::new(File::open(&input_path).unwrap()); let mut writer = BufWriter::new(File::create(&output_path).unwrap()); let mut count = 0; let mut pending = Vec::new(); for line in reader.lines() { let line = line.unwrap(); let trimmed = line.trim(); if trimmed.is_empty() { continue; } let Some(v0) = convert_line(trimmed, &mut pending).unwrap() else { continue; }; serde_json::to_writer(&mut writer, &v0).unwrap(); writer.write_all(b"\n").unwrap(); count += 1; } writer.flush().unwrap(); assert_eq!(count, 5); // Verify every output line parses as v0 let output = std::fs::read_to_string(&output_path).unwrap(); for line in output.lines() { let _: ChatRequestMessage = serde_json::from_str(line) .expect("each output line should be valid v0 ChatRequestMessage"); } } /// Full-file round trip with the new-shape sibling Reasoning lines /// interleaved between turns. Verifies the binary's buffered /// extraction folds correctly across the whole stream. #[test] fn test_full_file_roundtrip_with_sibling_reasoning() { let v1_lines = [ r#"{"type":"system","content":"sys"}"#, r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#, r#"{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"think 1"}]}"#, r#"{"type":"assistant","content":"a1","tool_calls":[]}"#, r#"{"type":"user","content":[{"type":"text","text":"q2"}]}"#, r#"{"type":"reasoning","id":"rs_2","summary":[{"type":"summary_text","text":"think 2"}]}"#, r#"{"type":"assistant","content":"a2","tool_calls":[]}"#, ]; let dir = tempfile::tempdir().unwrap(); let input_path = dir.path().join("input.jsonl"); let output_path = dir.path().join("output.jsonl"); std::fs::write(&input_path, v1_lines.join("\n") + "\n").unwrap(); let reader = BufReader::new(File::open(&input_path).unwrap()); let mut writer = BufWriter::new(File::create(&output_path).unwrap()); let mut pending = Vec::new(); let mut count = 0; for line in reader.lines() { let line = line.unwrap(); let trimmed = line.trim(); if trimmed.is_empty() { continue; } if let Some(v0) = convert_line(trimmed, &mut pending).unwrap() { serde_json::to_writer(&mut writer, &v0).unwrap(); writer.write_all(b"\n").unwrap(); count += 1; } } writer.flush().unwrap(); // 5 emitted lines: sys, q1, a1, q2, a2 (two reasoning lines folded in). assert_eq!(count, 5); let output = std::fs::read_to_string(&output_path).unwrap(); let lines: Vec<&str> = output.lines().collect(); assert_eq!(lines.len(), 5); let a1: serde_json::Value = serde_json::from_str(lines[2]).unwrap(); assert_eq!(a1["role"], "assistant"); assert_eq!(a1["content"], "a1"); assert_eq!(a1["reasoning_content"], "think 1"); let a2: serde_json::Value = serde_json::from_str(lines[4]).unwrap(); assert_eq!(a2["role"], "assistant"); assert_eq!(a2["content"], "a2"); assert_eq!(a2["reasoning_content"], "think 2"); } }