Skip to main content

whycodes_session/
import.rs

1//! Import transcripts from whycodes shares and other harnesses.
2//!
3//! Best-effort: unknown parts become text. Tools do not replay.
4
5use crate::error::{Result, SessionError};
6use whycodes_core::types::{Message, MessageContent, Role};
7
8/// Source format. `Auto` peeks at the file.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ImportKind {
11    Auto,
12    WhyCodes,
13    Claude,
14    Codex,
15    OpenCode,
16    Pi,
17}
18
19impl ImportKind {
20    pub fn parse(s: &str) -> Self {
21        match s.trim().to_ascii_lowercase().as_str() {
22            "whycodes" | "native" => Self::WhyCodes,
23            "claude" | "claude-code" => Self::Claude,
24            "codex" => Self::Codex,
25            "opencode" => Self::OpenCode,
26            "pi" => Self::Pi,
27            _ => Self::Auto,
28        }
29    }
30}
31
32/// Parse `raw` into native messages.
33pub fn import_messages(raw: &str, kind: ImportKind) -> Result<Vec<Message>> {
34    let kind = if kind == ImportKind::Auto {
35        detect(raw)
36    } else {
37        kind
38    };
39    let msgs = match kind {
40        ImportKind::Auto | ImportKind::WhyCodes => parse_whycodes(raw)?,
41        ImportKind::Claude => parse_claude(raw)?,
42        ImportKind::Codex => parse_codex(raw)?,
43        ImportKind::OpenCode => parse_opencode(raw)?,
44        ImportKind::Pi => parse_pi(raw)?,
45    };
46    if msgs.is_empty() {
47        return Err(SessionError::msg(
48            "no user/assistant messages found in import",
49        ));
50    }
51    Ok(msgs)
52}
53
54fn detect(raw: &str) -> ImportKind {
55    let trimmed = raw.trim_start();
56    if trimmed.starts_with('{')
57        && let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed)
58    {
59        if v.get("info").is_some() && v.get("messages").is_some() {
60            return ImportKind::OpenCode;
61        }
62        if v.get("id").is_some() && v.get("messages").is_some() {
63            return ImportKind::WhyCodes;
64        }
65        if v.get("session").is_some() && v.get("messages").is_some() {
66            return ImportKind::WhyCodes;
67        }
68    }
69    let Some(first) = first_jsonl_object(raw) else {
70        return ImportKind::WhyCodes;
71    };
72    let ty = first.get("type").and_then(|t| t.as_str()).unwrap_or("");
73    if ty == "session_meta" || ty.starts_with("event") || first.get("payload").is_some() {
74        return ImportKind::Codex;
75    }
76    if first.get("type").is_some() && first.get("message").is_some() {
77        return ImportKind::Claude;
78    }
79    if first.get("role").is_some() && first.get("content").is_some() {
80        return ImportKind::Pi;
81    }
82    ImportKind::WhyCodes
83}
84
85fn first_jsonl_object(raw: &str) -> Option<serde_json::Value> {
86    for line in raw.lines() {
87        let line = line.trim();
88        if line.is_empty() {
89            continue;
90        }
91        if let Ok(v) = serde_json::from_str(line) {
92            return Some(v);
93        }
94    }
95    None
96}
97
98fn parse_whycodes(raw: &str) -> Result<Vec<Message>> {
99    let v: serde_json::Value = serde_json::from_str(raw)?;
100    if let Some(arr) = v.get("messages").and_then(|m| m.as_array()) {
101        return Ok(arr.iter().filter_map(value_to_message).collect());
102    }
103    if let Some(arr) = v.as_array() {
104        return Ok(arr.iter().filter_map(value_to_message).collect());
105    }
106    Err(SessionError::msg(
107        "not a whycodes session JSON (expected messages array)",
108    ))
109}
110
111fn parse_opencode(raw: &str) -> Result<Vec<Message>> {
112    parse_whycodes(raw)
113}
114
115fn parse_claude(raw: &str) -> Result<Vec<Message>> {
116    let mut out = Vec::new();
117    for line in raw.lines() {
118        let line = line.trim();
119        if line.is_empty() {
120            continue;
121        }
122        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
123            continue;
124        };
125        let msg = v.get("message").unwrap_or(&v);
126        if let Some(m) = value_to_message(msg) {
127            out.push(m);
128        }
129    }
130    Ok(out)
131}
132
133fn parse_codex(raw: &str) -> Result<Vec<Message>> {
134    let mut out = Vec::new();
135    for line in raw.lines() {
136        let line = line.trim();
137        if line.is_empty() {
138            continue;
139        }
140        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
141            continue;
142        };
143        let payload = v.get("payload").unwrap_or(&v);
144        let ty = payload
145            .get("type")
146            .or_else(|| v.get("type"))
147            .and_then(|t| t.as_str())
148            .unwrap_or("");
149        if let Some(text) = payload
150            .get("message")
151            .and_then(|m| m.as_str())
152            .or_else(|| payload.get("text").and_then(|t| t.as_str()))
153        {
154            let role = if ty.contains("user") {
155                Role::User
156            } else if ty.contains("agent") || ty.contains("assistant") {
157                Role::Assistant
158            } else {
159                continue;
160            };
161            out.push(
162                Message {
163                    role,
164                    content: MessageContent::text(text),
165                    tool_call_id: None,
166                    name: None,
167                    created_at: None,
168                }
169                .stamp(),
170            );
171        } else if let Some(m) = value_to_message(payload) {
172            out.push(m);
173        }
174    }
175    Ok(out)
176}
177
178fn parse_pi(raw: &str) -> Result<Vec<Message>> {
179    let mut out = Vec::new();
180    for line in raw.lines() {
181        let line = line.trim();
182        if line.is_empty() {
183            continue;
184        }
185        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
186            continue;
187        };
188        if let Some(m) = value_to_message(&v) {
189            out.push(m);
190        }
191    }
192    Ok(out)
193}
194
195fn value_to_message(v: &serde_json::Value) -> Option<Message> {
196    let role = v.get("role").and_then(|r| r.as_str())?;
197    let role = match role {
198        "user" => Role::User,
199        "assistant" => Role::Assistant,
200        "system" => return None,
201        "tool" => Role::Tool,
202        _ => return None,
203    };
204    let content = v.get("content")?;
205    let content = if let Some(s) = content.as_str() {
206        MessageContent::text(s)
207    } else if let Some(arr) = content.as_array() {
208        let text = arr
209            .iter()
210            .filter_map(|b| {
211                b.get("text")
212                    .and_then(|t| t.as_str())
213                    .or_else(|| b.as_str())
214            })
215            .collect::<Vec<_>>()
216            .join("\n");
217        if text.is_empty() {
218            MessageContent::text(content.to_string())
219        } else {
220            MessageContent::text(text)
221        }
222    } else {
223        MessageContent::text(content.to_string())
224    };
225    Some(
226        Message {
227            role,
228            content,
229            tool_call_id: v
230                .get("tool_call_id")
231                .and_then(|t| t.as_str())
232                .map(str::to_string),
233            name: v.get("name").and_then(|n| n.as_str()).map(str::to_string),
234            created_at: None,
235        }
236        .stamp(),
237    )
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn claude_jsonl() {
246        let raw = r#"
247{"type":"user","message":{"role":"user","content":"hello"}}
248{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]}}
249"#;
250        let msgs = import_messages(raw, ImportKind::Auto).unwrap();
251        assert_eq!(msgs.len(), 2);
252        assert_eq!(msgs[0].role, Role::User);
253        assert_eq!(msgs[0].content.as_text(), Some("hello"));
254        assert_eq!(msgs[1].content.as_text(), Some("hi there"));
255    }
256
257    #[test]
258    fn whycodes_share() {
259        let raw = r#"{"id":"abc","title":"t","messages":[{"role":"user","content":"ping"}]}"#;
260        let msgs = import_messages(raw, ImportKind::Auto).unwrap();
261        assert_eq!(msgs.len(), 1);
262        assert_eq!(msgs[0].content.as_text(), Some("ping"));
263    }
264
265    #[test]
266    fn codex_jsonl() {
267        let raw = r#"
268{"type":"session_meta","payload":{"id":"x"}}
269{"type":"event_msg","payload":{"type":"user_message","message":"fix the bug"}}
270{"type":"event_msg","payload":{"type":"agent_message","message":"looking"}}
271"#;
272        let msgs = import_messages(raw, ImportKind::Auto).unwrap();
273        assert_eq!(msgs.len(), 2);
274        assert_eq!(msgs[0].role, Role::User);
275        assert_eq!(msgs[1].role, Role::Assistant);
276    }
277
278    #[test]
279    fn import_kind_parse_covers_aliases_and_fallback() {
280        assert_eq!(ImportKind::parse(" native "), ImportKind::WhyCodes);
281        assert_eq!(ImportKind::parse("whycodes"), ImportKind::WhyCodes);
282        assert_eq!(ImportKind::parse("CLAUDE-CODE"), ImportKind::Claude);
283        assert_eq!(ImportKind::parse("codex"), ImportKind::Codex);
284        assert_eq!(ImportKind::parse("opencode"), ImportKind::OpenCode);
285        assert_eq!(ImportKind::parse("pi"), ImportKind::Pi);
286        assert_eq!(ImportKind::parse("unknown"), ImportKind::Auto);
287    }
288
289    #[test]
290    fn explicit_import_kinds_and_empty_errors() {
291        let native = r#"[{"role":"user","content":"hello"}]"#;
292        assert_eq!(
293            import_messages(native, ImportKind::WhyCodes).unwrap().len(),
294            1
295        );
296        assert_eq!(
297            import_messages(native, ImportKind::OpenCode).unwrap().len(),
298            1
299        );
300
301        let claude = "not json\n{\"role\":\"assistant\",\"content\":\"answer\"}";
302        assert_eq!(
303            import_messages(claude, ImportKind::Claude).unwrap().len(),
304            1
305        );
306        let pi = "\ninvalid\n{\"role\":\"user\",\"content\":\"question\"}";
307        assert_eq!(import_messages(pi, ImportKind::Pi).unwrap().len(), 1);
308
309        let err = import_messages(r#"{"messages":[]}"#, ImportKind::WhyCodes).unwrap_err();
310        assert!(err.to_string().contains("no user/assistant"));
311        assert!(import_messages("{}", ImportKind::WhyCodes).is_err());
312        assert!(import_messages("not json", ImportKind::WhyCodes).is_err());
313    }
314
315    #[test]
316    fn auto_detects_all_supported_shapes() {
317        let cases = [
318            (
319                r#"{"info":{},"messages":[{"role":"user","content":"o"}]}"#,
320                "o",
321            ),
322            (
323                r#"{"session":{},"messages":[{"role":"user","content":"w"}]}"#,
324                "w",
325            ),
326            (
327                "{\"type\":\"event_custom\",\"payload\":{}}\n{\"payload\":{\"type\":\"user_message\",\"text\":\"c\"}}",
328                "c",
329            ),
330            (
331                "{\"payload\":{}}\n{\"payload\":{\"type\":\"assistant_message\",\"message\":\"a\"}}",
332                "a",
333            ),
334            ("{\"role\":\"user\",\"content\":\"p\"}", "p"),
335        ];
336        for (raw, expected) in cases {
337            let messages = import_messages(raw, ImportKind::Auto).unwrap();
338            assert_eq!(messages[0].content.as_text(), Some(expected));
339        }
340    }
341
342    #[test]
343    fn codex_skips_noise_and_accepts_native_payload_messages() {
344        let raw = r#"
345
346not-json
347{"type":"event_msg","payload":{"type":"irrelevant","message":"skip"}}
348{"type":"user_message","text":"direct user"}
349{"payload":{"role":"assistant","content":"native assistant"}}
350{"payload":{"role":"system","content":"hidden"}}
351"#;
352        let msgs = import_messages(raw, ImportKind::Codex).unwrap();
353        assert_eq!(msgs.len(), 2);
354        assert_eq!(msgs[0].role, Role::User);
355        assert_eq!(msgs[1].role, Role::Assistant);
356    }
357
358    #[test]
359    fn value_conversion_handles_roles_content_shapes_and_metadata() {
360        let raw = r#"[
361          {"role":"tool","content":[{"text":"one"},"two",{"other":1}],"tool_call_id":"call","name":"read"},
362          {"role":"assistant","content":[{"image":"x"}]},
363          {"role":"user","content":{"nested":true}},
364          {"role":"system","content":"skip"},
365          {"role":"unknown","content":"skip"},
366          {"content":"skip"},
367          {"role":"user"}
368        ]"#;
369        let msgs = import_messages(raw, ImportKind::WhyCodes).unwrap();
370        assert_eq!(msgs.len(), 3);
371        assert_eq!(msgs[0].role, Role::Tool);
372        assert_eq!(msgs[0].content.as_text(), Some("one\ntwo"));
373        assert_eq!(msgs[0].tool_call_id.as_deref(), Some("call"));
374        assert_eq!(msgs[0].name.as_deref(), Some("read"));
375        assert_eq!(msgs[1].content.as_text(), Some("[{\"image\":\"x\"}]"));
376        assert_eq!(msgs[2].content.as_text(), Some("{\"nested\":true}"));
377    }
378
379    #[test]
380    fn direct_parsers_cover_remaining_detection_and_loop_fallbacks() {
381        assert_eq!(detect("\ninvalid\n{}"), ImportKind::WhyCodes);
382        assert_eq!(
383            detect("{\"unrecognized\":true}\nnot-json"),
384            ImportKind::WhyCodes
385        );
386        assert_eq!(detect("{}\n"), ImportKind::WhyCodes);
387        assert_eq!(detect("not-json\n{}"), ImportKind::WhyCodes);
388        assert_eq!(detect("not-json-only"), ImportKind::WhyCodes);
389        assert!(first_jsonl_object("\ninvalid\nalso-invalid").is_none());
390        assert_eq!(
391            import_messages(
392                "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"a\"}}",
393                ImportKind::Claude,
394            )
395            .unwrap()[0]
396                .role,
397            Role::Assistant
398        );
399        assert_eq!(
400            import_messages("{\"role\":\"tool\",\"content\":\"r\"}\n", ImportKind::Pi,).unwrap()[0]
401                .role,
402            Role::Tool
403        );
404        for kind in [ImportKind::Claude, ImportKind::Codex, ImportKind::Pi] {
405            assert!(import_messages("\ninvalid", kind).is_err());
406        }
407    }
408}