Skip to main content

agent_sessions/
projection.rs

1//! Tolerant projections for consumers that retain the original JSON record.
2//! These helpers do not validate records or establish snapshot completeness.
3use crate::{Agent, Message, MetaUpdate, Role};
4use chrono::{DateTime, Utc};
5use serde_json::Value;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ConversationMessage {
9    pub role: Role,
10    pub text: String,
11    pub created_at_epoch: Option<i64>,
12}
13
14/// First present native timestamp field, in seconds. Invalid values stay absent;
15/// a malformed earlier field does not fall through to a later field.
16pub fn tolerant_timestamp_epoch(v: &Value) -> Option<i64> {
17    let t = v
18        .get("timestamp")
19        .or_else(|| v.get("created_at"))
20        .or_else(|| v.get("createdAt"))
21        .or_else(|| v.pointer("/payload/timestamp"))?;
22    t.as_i64()
23        .or_else(|| t.as_str()?.trim().parse().ok())
24        .or_else(|| {
25            DateTime::parse_from_rfc3339(t.as_str()?.trim())
26                .ok()
27                .map(|d| d.timestamp())
28        })
29}
30
31fn text(
32    content: Option<&Value>,
33    allowed: &[&str],
34    strings: bool,
35) -> (String, Vec<std::ops::Range<usize>>) {
36    let Some(content) = content else {
37        return (String::new(), Vec::new());
38    };
39    let parts: Vec<&str> = if strings && content.is_string() {
40        content.as_str().into_iter().collect()
41    } else {
42        content
43            .as_array()
44            .into_iter()
45            .flatten()
46            .filter_map(|block| {
47                allowed
48                    .contains(&block.get("type")?.as_str()?)
49                    .then(|| block.get("text")?.as_str())
50                    .flatten()
51            })
52            .collect()
53    };
54    let mut offset = 0;
55    let segments = parts
56        .iter()
57        .map(|s| {
58            let range = offset..offset + s.len();
59            offset += s.len() + 1;
60            range
61        })
62        .collect();
63    (parts.join("\n"), segments)
64}
65
66/// Project user/assistant records without filtering meta/control content. Empty
67/// or malformed content remains an empty message for archival classification.
68pub fn project_conversation(v: &Value) -> Option<ConversationMessage> {
69    let (role, content) = match v.get("type")?.as_str()? {
70        "user" => (Role::User, v.pointer("/message/content")),
71        "assistant" => (Role::Assistant, v.pointer("/message/content")),
72        "response_item" if v.pointer("/payload/type")?.as_str()? == "message" => {
73            let role = match v.pointer("/payload/role")?.as_str()? {
74                "user" => Role::User,
75                "assistant" => Role::Assistant,
76                _ => return None,
77            };
78            (role, v.pointer("/payload/content"))
79        }
80        _ => return None,
81    };
82    Some(ConversationMessage {
83        role,
84        text: text(content, &["text", "input_text", "output_text"], true).0,
85        created_at_epoch: tolerant_timestamp_epoch(v),
86    })
87}
88
89/// Native fields are borrowed without coercion; callers decide whether arguments
90/// and outputs must be strings, objects, or other JSON types.
91#[derive(Debug, Clone, Copy, PartialEq)]
92pub enum CodexFunction<'a> {
93    Call {
94        call_id: Option<&'a str>,
95        name: Option<&'a str>,
96        arguments: Option<&'a Value>,
97    },
98    Output {
99        call_id: Option<&'a str>,
100        output: Option<&'a Value>,
101    },
102}
103pub fn project_codex_function(v: &Value) -> Option<CodexFunction<'_>> {
104    if v.get("type")?.as_str()? != "response_item" {
105        return None;
106    }
107    let p = v.get("payload")?;
108    let call_id = p.get("call_id").and_then(Value::as_str);
109    match p.get("type")?.as_str()? {
110        "function_call" => Some(CodexFunction::Call {
111            call_id,
112            name: p.get("name").and_then(Value::as_str),
113            arguments: p.get("arguments"),
114        }),
115        "function_call_output" => Some(CodexFunction::Output {
116            call_id,
117            output: p.get("output"),
118        }),
119        _ => None,
120    }
121}
122
123#[derive(Debug, Clone, PartialEq)]
124pub struct TranscriptProjection {
125    pub message: Option<Message>,
126    pub meta: MetaUpdate,
127    pub at: Option<DateTime<Utc>>,
128}
129
130/// Tolerant native transcript projection, including legacy Codex envelopes.
131/// Claude accepts text blocks; current Codex accepts role-specific text blocks.
132/// Unknown fields do not invalidate valid text. JSON syntax and tail policy are
133/// the caller's responsibility. Meta messages are tagged, not filtered.
134pub fn project_transcript(agent: Agent, v: &Value) -> TranscriptProjection {
135    let at = [v.get("timestamp"), v.pointer("/payload/timestamp")]
136        .into_iter()
137        .flatten()
138        .filter_map(Value::as_str)
139        .find_map(|s| {
140            DateTime::parse_from_rfc3339(s)
141                .ok()
142                .map(|d| d.with_timezone(&Utc))
143        });
144    let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
145    let mut meta = MetaUpdate::default();
146    let mut candidate = None;
147    match agent {
148        Agent::ClaudeCode => {
149            meta.cwd = crate::parser::string(v, "cwd");
150            if kind == "system" {
151                meta.model = v
152                    .pointer("/message/model")
153                    .and_then(Value::as_str)
154                    .map(str::to_owned);
155            }
156            if matches!(kind, "user" | "assistant") {
157                candidate = Some((
158                    if kind == "user" {
159                        Role::User
160                    } else {
161                        Role::Assistant
162                    },
163                    text(v.pointer("/message/content"), &["text"], true),
164                ));
165            }
166        }
167        Agent::Codex => match kind {
168            "session_meta" | "turn_context" => {
169                let p = v.get("payload").unwrap_or(&Value::Null);
170                meta.model = p
171                    .get("model")
172                    .or_else(|| (kind == "session_meta").then(|| v.get("model")).flatten())
173                    .and_then(Value::as_str)
174                    .map(str::to_owned);
175                meta.cwd = crate::parser::string(p, "cwd");
176                meta.origin = Some(crate::codex::origin::classify(p));
177                meta.source = p.get("source").cloned();
178                meta.originator = crate::parser::string(p, "originator");
179                meta.thread_source = crate::parser::string(p, "thread_source");
180            }
181            "user_message" => {
182                if v.get("content").is_some_and(Value::is_string) {
183                    candidate = Some((Role::User, text(v.get("content"), &[], true)));
184                }
185            }
186            "response_item" => {
187                let p = v.get("payload").unwrap_or(&Value::Null);
188                let ty = p.get("type").and_then(Value::as_str);
189                let role = p.get("role").and_then(Value::as_str);
190                let selection: Option<(Role, &[&str])> = match (ty, role) {
191                    (Some("message"), Some("user")) => Some((Role::User, &["input_text", "text"])),
192                    (Some("message"), Some("assistant")) => {
193                        Some((Role::Assistant, &["output_text", "text"]))
194                    }
195                    (None, _) => Some((Role::Assistant, &["text"])),
196                    _ => None,
197                };
198                if let Some((role, allowed)) = selection {
199                    candidate = Some((role, text(p.get("content"), allowed, false)));
200                }
201            }
202            _ => {}
203        },
204    }
205    let message =
206        candidate
207            .filter(|(_, (body, _))| !body.is_empty())
208            .map(|(role, (text, text_segments))| Message {
209                role,
210                text,
211                text_segments,
212                is_meta: v
213                    .get("isMeta")
214                    .or_else(|| v.get("is_meta"))
215                    .or_else(|| v.pointer("/message/isMeta"))
216                    .or_else(|| v.pointer("/message/is_meta"))
217                    .and_then(Value::as_bool)
218                    .unwrap_or(false),
219                is_sidechain: v
220                    .get("isSidechain")
221                    .and_then(Value::as_bool)
222                    .unwrap_or(false),
223                parent_id: crate::parser::string(v, "parentUuid"),
224            });
225    TranscriptProjection { message, meta, at }
226}