Skip to main content

gate4agent_adapters/
history.rs

1use gate4agent_types::AdapterId;
2use serde_json::{Map, Value};
3use std::collections::{HashSet, VecDeque};
4use thiserror::Error;
5
6pub const HISTORY_METADATA_MAX_BYTES: usize = 1_048_576;
7pub const HISTORY_DOCUMENT_MAX_BYTES: usize = 8_388_608;
8pub const HISTORY_STORED_MESSAGES_MAX: usize = 256;
9pub const HISTORY_MESSAGE_MAX_CHARS: usize = 4_096;
10const HISTORY_TITLE_MAX_CHARS: usize = 96;
11const HISTORY_PROVIDER_MESSAGE_ID_MAX_BYTES: usize = 512;
12
13#[derive(Clone, Debug, Default, Eq, PartialEq)]
14pub struct HistoryDocument {
15    /// Required fallback because the pure adapter has no filesystem path from
16    /// which to infer the provider session ID.
17    pub session_id_hint: String,
18    /// Summary/state JSON for providers that split metadata and transcript.
19    pub metadata_json: Option<String>,
20    /// Provider transcript content, usually NDJSON.
21    pub transcript: String,
22}
23
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct HistorySession {
26    pub session_id: String,
27    pub title: Option<String>,
28    pub cwd: Option<String>,
29    pub model: Option<String>,
30    pub message_count: u64,
31    pub completed_turn_count: Option<u64>,
32    pub total_tokens: u64,
33    pub total_tokens_observed: bool,
34    pub messages: Vec<HistoryMessage>,
35}
36
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct HistoryMessage {
39    pub role: HistoryRole,
40    pub text: String,
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum HistoryRole {
45    User,
46    Assistant,
47}
48
49#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
50pub enum HistorySourceLayout {
51    SingleNdjson,
52    SingleJson,
53    JsonOrNdjson,
54    NdjsonWithOptionalIndex,
55    SummaryJsonWithSiblingNdjson,
56    MetadataJsonWithSiblingJson,
57    SessionJsonWithSiblingMessageJson,
58    ReadOnlySqliteProjection,
59    StateJsonWithIndexAndSiblingNdjson,
60}
61
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub struct HistorySourceVariant {
64    pub layout: HistorySourceLayout,
65    pub requires_sibling_reads: bool,
66    pub requires_auxiliary_index: bool,
67    pub requires_readonly_database: bool,
68}
69
70/// Declares the provider-owned source layouts an effect-owning history shell
71/// may project into [`HistoryDocument`]. The pure adapter never opens paths or
72/// databases; callers must bound and authorize every candidate and related
73/// read before supplying metadata/transcript content.
74pub fn history_source_variants(
75    adapter_id: &AdapterId,
76) -> Result<&'static [HistorySourceVariant], HistoryAdapterError> {
77    const NDJSON: HistorySourceVariant = HistorySourceVariant {
78        layout: HistorySourceLayout::SingleNdjson,
79        requires_sibling_reads: false,
80        requires_auxiliary_index: false,
81        requires_readonly_database: false,
82    };
83    const CODEX: HistorySourceVariant = HistorySourceVariant {
84        layout: HistorySourceLayout::NdjsonWithOptionalIndex,
85        requires_sibling_reads: false,
86        requires_auxiliary_index: true,
87        requires_readonly_database: false,
88    };
89    const GROK: HistorySourceVariant = HistorySourceVariant {
90        layout: HistorySourceLayout::SummaryJsonWithSiblingNdjson,
91        requires_sibling_reads: true,
92        requires_auxiliary_index: false,
93        requires_readonly_database: false,
94    };
95    const ROVO: HistorySourceVariant = HistorySourceVariant {
96        layout: HistorySourceLayout::MetadataJsonWithSiblingJson,
97        requires_sibling_reads: true,
98        requires_auxiliary_index: false,
99        requires_readonly_database: false,
100    };
101    const KIMI: HistorySourceVariant = HistorySourceVariant {
102        layout: HistorySourceLayout::StateJsonWithIndexAndSiblingNdjson,
103        requires_sibling_reads: true,
104        requires_auxiliary_index: true,
105        requires_readonly_database: false,
106    };
107
108    match adapter_id.as_str() {
109        "claude-code" | "cursor" | "openclaw" | "pi" | "omp" => Ok(&[NDJSON]),
110        "codex" => Ok(&[CODEX]),
111        "grok" => Ok(&[GROK]),
112        "rovo" => Ok(&[ROVO]),
113        "kimi" => Ok(&[KIMI]),
114        id => Err(HistoryAdapterError::UnsupportedAdapter(id.to_owned())),
115    }
116}
117
118pub fn parse_history(
119    adapter_id: &AdapterId,
120    document: &HistoryDocument,
121) -> Result<HistorySession, HistoryAdapterError> {
122    validate_document(document)?;
123    match adapter_id.as_str() {
124        "claude-code" => parse_claude(document),
125        "codex" => parse_codex(document),
126        "rovo" => parse_rovo(document),
127        "openclaw" | "pi" | "omp" => parse_message_graph(document),
128        "grok" => parse_grok(document),
129        "kimi" => parse_kimi(document),
130        "cursor" => parse_cursor(document),
131        id => Err(HistoryAdapterError::UnsupportedAdapter(id.to_owned())),
132    }
133}
134
135fn validate_document(document: &HistoryDocument) -> Result<(), HistoryAdapterError> {
136    if document.transcript.len() > HISTORY_DOCUMENT_MAX_BYTES {
137        return Err(HistoryAdapterError::TranscriptTooLarge);
138    }
139    if document
140        .metadata_json
141        .as_ref()
142        .is_some_and(|metadata| metadata.len() > HISTORY_METADATA_MAX_BYTES)
143    {
144        return Err(HistoryAdapterError::MetadataTooLarge);
145    }
146    validate_session_id(&document.session_id_hint)
147}
148
149fn parse_claude(document: &HistoryDocument) -> Result<HistorySession, HistoryAdapterError> {
150    let mut session = SessionBuilder::new(document.session_id_hint.trim().to_owned());
151    let mut completed_turn_ids = HashSet::new();
152    let mut custom_title = None;
153    let mut generated_title = None;
154    let mut first_user_title = None;
155    let mut meta_title = None;
156
157    for record in ndjson_records(&document.transcript) {
158        if let Some(id) = string(&record, &["sessionId"]) {
159            session.session_id = select_session_id(Some(id), &session.session_id)?;
160        }
161        session.cwd = string(&record, &["cwd"]).or(session.cwd);
162        match record.get("type").and_then(Value::as_str) {
163            Some("custom-title") => {
164                custom_title = string(&record, &["customTitle"]).and_then(normalize_title);
165            }
166            Some("ai-title") => {
167                if let Some(title) = string(&record, &["aiTitle"]).and_then(normalize_title) {
168                    generated_title = Some(title);
169                }
170            }
171            Some("agent-name") if generated_title.is_none() => {
172                meta_title = meta_title
173                    .or_else(|| string(&record, &["agentName"]).and_then(normalize_title));
174            }
175            Some("user") => {
176                let message = record.get("message").and_then(Value::as_object);
177                let is_meta = record.get("isMeta").and_then(Value::as_bool) == Some(true);
178                let text = message
179                    .and_then(|message| message.get("content"))
180                    .and_then(claude_visible_message_text);
181                let is_harness_injected = text
182                    .as_deref()
183                    .is_some_and(is_known_harness_injected_user_turn);
184                if !is_meta && !is_harness_injected {
185                    first_user_title = first_user_title
186                        .or_else(|| text.clone().and_then(normalize_title));
187                    session.push(HistoryRole::User, text);
188                }
189            }
190            Some("assistant") => {
191                let message = record.get("message").and_then(Value::as_object);
192                if let Some(message) = message {
193                    if let Some(message_id) = claude_completed_turn_message_id(&record, message) {
194                        completed_turn_ids.insert(message_id);
195                    }
196                }
197                session.model = message
198                    .and_then(|message| string(message, &["model"]))
199                    .or(session.model);
200                if let Some(usage) = message.and_then(|message| message.get("usage")) {
201                    session.add_total_tokens(claude_usage_total(usage));
202                }
203                session.push(
204                    HistoryRole::Assistant,
205                    message
206                        .and_then(|message| message.get("content"))
207                        .and_then(claude_visible_message_text),
208                );
209            }
210            _ => {}
211        }
212    }
213    session.title = custom_title
214        .or(generated_title)
215        .or(first_user_title)
216        .or(meta_title);
217    session.completed_turn_count = Some(completed_turn_ids.len() as u64);
218    Ok(session.finish())
219}
220
221fn parse_codex(document: &HistoryDocument) -> Result<HistorySession, HistoryAdapterError> {
222    let mut session = SessionBuilder::new(document.session_id_hint.trim().to_owned());
223    let mut saw_session_meta = false;
224    let mut metadata_title = None;
225    let mut user_title = None;
226    let mut previous_usage = None;
227
228    for record in ndjson_records(&document.transcript) {
229        let payload = record.get("payload").and_then(Value::as_object);
230        match (record.get("type").and_then(Value::as_str), payload) {
231            (Some("session_meta"), Some(payload)) => {
232                if is_codex_worker_session(payload) {
233                    return Err(HistoryAdapterError::ExcludedProviderSession);
234                }
235                saw_session_meta = true;
236                if let Some(id) = string(payload, &["id"]) {
237                    session.session_id = select_session_id(Some(id), &session.session_id)?;
238                }
239                metadata_title = string(payload, &["title", "thread_name", "threadName"])
240                    .and_then(normalize_title)
241                    .or(metadata_title);
242                session.cwd = string(payload, &["cwd"]).or(session.cwd);
243            }
244            (Some("turn_context"), Some(payload)) => {
245                session.cwd = string(payload, &["cwd"]).or(session.cwd);
246                session.model = model_from_nested_record(payload).or(session.model);
247            }
248            (Some("response_item"), Some(payload))
249                if payload.get("type").and_then(Value::as_str) == Some("message") =>
250            {
251                let role = role_from_value(payload.get("role"));
252                if let Some(role) = role {
253                    let text = payload.get("content").and_then(content_text);
254                    if role == HistoryRole::User {
255                        user_title = user_title.or_else(|| text.clone().and_then(normalize_title));
256                    }
257                    session.push(role, text);
258                } else {
259                    session.message_count = session.message_count.saturating_add(1);
260                }
261            }
262            (Some("event_msg"), Some(payload)) => {
263                match payload.get("type").and_then(Value::as_str) {
264                    Some("user_message") => {
265                        let text = payload.get("message").and_then(content_text);
266                        user_title = user_title.or_else(|| text.clone().and_then(normalize_title));
267                        session.push(HistoryRole::User, text);
268                    }
269                    Some("agent_message") => session.push(
270                        HistoryRole::Assistant,
271                        payload.get("message").and_then(content_text),
272                    ),
273                    Some("token_count") => {
274                        if let Some(info) = payload.get("info").and_then(Value::as_object) {
275                            let total = info
276                                .get("total_token_usage")
277                                .and_then(normalize_codex_usage);
278                            let last = info.get("last_token_usage").and_then(normalize_codex_usage);
279                            if let Some(total) = total {
280                                session.total_tokens_observed = true;
281                                session.total_tokens = session.total_tokens.saturating_add(
282                                    total.total_tokens.saturating_sub(
283                                        previous_usage
284                                            .map(|usage: CodexUsage| usage.total_tokens)
285                                            .unwrap_or(0),
286                                    ),
287                                );
288                                previous_usage = Some(total);
289                            } else if let Some(last) = last {
290                                session.total_tokens_observed = true;
291                                session.total_tokens =
292                                    session.total_tokens.saturating_add(last.total_tokens);
293                                previous_usage = Some(previous_usage.unwrap_or_default().add(last));
294                            }
295                        }
296                        session.model = model_from_nested_record(payload).or(session.model);
297                    }
298                    _ => {}
299                }
300            }
301            _ => {}
302        }
303    }
304    let indexed_title = if saw_session_meta {
305        document
306            .metadata_json
307            .as_deref()
308            .and_then(|metadata| serde_json::from_str::<Value>(metadata).ok())
309            .and_then(|value| value.as_object().cloned())
310            .and_then(|metadata| string(&metadata, &["indexed_title", "title"]))
311            .and_then(normalize_title)
312    } else {
313        None
314    };
315    session.title = metadata_title.or(indexed_title).or(user_title);
316    Ok(session.finish())
317}
318
319fn parse_grok(document: &HistoryDocument) -> Result<HistorySession, HistoryAdapterError> {
320    let metadata = metadata_object(document)?;
321    let info = metadata.get("info").and_then(Value::as_object);
322    let mut session = SessionBuilder::new(select_session_id(
323        info.and_then(|value| string(value, &["id"])),
324        &document.session_id_hint,
325    )?);
326    session.cwd = info.and_then(|value| string(value, &["cwd"]));
327    session.title =
328        string(&metadata, &["generated_title", "session_summary"]).and_then(normalize_title);
329    session.model = string(&metadata, &["current_model_id"]);
330    let declared_count = u64_value(&metadata, &["num_chat_messages"])
331        .filter(|count| *count > 0)
332        .or_else(|| u64_value(&metadata, &["num_messages"]));
333
334    for record in ndjson_records(&document.transcript) {
335        let Some(role) = role_from_value(record.get("type")) else {
336            continue;
337        };
338        let text = record.get("content").and_then(grok_content_text);
339        session.push(role, text);
340    }
341    if let Some(declared_count) = declared_count {
342        session.message_count = declared_count;
343    }
344    Ok(session.finish())
345}
346
347fn parse_kimi(document: &HistoryDocument) -> Result<HistorySession, HistoryAdapterError> {
348    let metadata = metadata_object(document)?;
349    let mut session = SessionBuilder::new(document.session_id_hint.trim().to_owned());
350    session.title = string(&metadata, &["title"]).and_then(normalize_title);
351    session.cwd = string(&metadata, &["cwd", "workDir"]);
352    let fallback_title = string(&metadata, &["lastPrompt"]).and_then(normalize_title);
353    let mut assistant_parts = Vec::new();
354
355    for record in ndjson_records(&document.transcript) {
356        match record.get("type").and_then(Value::as_str) {
357            Some("config.update") => {
358                session.model = string(&record, &["modelAlias"]).or(session.model);
359            }
360            Some("usage.record")
361                if record.get("usageScope").and_then(Value::as_str) != Some("session") =>
362            {
363                session.model = string(&record, &["model"]).or(session.model);
364                if let Some(usage) = record.get("usage").and_then(Value::as_object) {
365                    session.add_total_tokens(sum_named_numbers(
366                        usage,
367                        &[
368                            "inputOther",
369                            "output",
370                            "inputCacheRead",
371                            "inputCacheCreation",
372                        ],
373                    ));
374                }
375            }
376            Some("context.append_message") => {
377                let Some(message) = record.get("message").and_then(Value::as_object) else {
378                    continue;
379                };
380                let is_real_user = message.get("role").and_then(Value::as_str) == Some("user")
381                    && message
382                        .get("origin")
383                        .and_then(Value::as_object)
384                        .and_then(|origin| origin.get("kind"))
385                        .and_then(Value::as_str)
386                        == Some("user");
387                if is_real_user {
388                    session.push(
389                        HistoryRole::User,
390                        message.get("content").and_then(content_text),
391                    );
392                }
393            }
394            Some("context.append_loop_event") => {
395                let Some(event) = record.get("event").and_then(Value::as_object) else {
396                    continue;
397                };
398                match event.get("type").and_then(Value::as_str) {
399                    Some("content.part") => {
400                        let part = event.get("part").and_then(Value::as_object);
401                        if part
402                            .and_then(|value| value.get("type"))
403                            .and_then(Value::as_str)
404                            == Some("text")
405                        {
406                            if let Some(text) = part
407                                .and_then(|value| value.get("text"))
408                                .and_then(Value::as_str)
409                            {
410                                assistant_parts.push(text.to_owned());
411                            }
412                        }
413                    }
414                    Some("step.end") => flush_assistant(&mut session, &mut assistant_parts),
415                    _ => {}
416                }
417            }
418            _ => {}
419        }
420    }
421    flush_assistant(&mut session, &mut assistant_parts);
422    session.title = session.title.or(fallback_title);
423    Ok(session.finish())
424}
425
426fn parse_cursor(document: &HistoryDocument) -> Result<HistorySession, HistoryAdapterError> {
427    let mut session = SessionBuilder::new(document.session_id_hint.trim().to_owned());
428    for record in ndjson_records(&document.transcript) {
429        let Some(role) = role_from_value(record.get("role")) else {
430            continue;
431        };
432        let text = record
433            .get("message")
434            .and_then(|message| {
435                message
436                    .as_object()
437                    .and_then(|value| value.get("content"))
438                    .or(Some(message))
439            })
440            .or_else(|| record.get("content"))
441            .and_then(content_text);
442        session.push(role, text);
443    }
444    Ok(session.finish())
445}
446
447fn parse_rovo(document: &HistoryDocument) -> Result<HistorySession, HistoryAdapterError> {
448    let metadata = metadata_object(document)?;
449    let mut session = SessionBuilder::new(document.session_id_hint.trim().to_owned());
450    session.title = string(&metadata, &["title", "name", "summary"]).and_then(normalize_title);
451    session.cwd = string(
452        &metadata,
453        &[
454            "workspace_path",
455            "workspacePath",
456            "workspace",
457            "cwd",
458            "working_directory",
459            "workingDirectory",
460            "project_path",
461            "projectPath",
462        ],
463    );
464    let context = transcript_object(document, true)?;
465    if let Some(messages) = context.get("messages").and_then(Value::as_array) {
466        for message in messages {
467            let Some(message) = message.as_object() else {
468                continue;
469            };
470            let Some(role) = role_from_value(message.get("role")) else {
471                continue;
472            };
473            session.push(role, message.get("content").and_then(content_text));
474        }
475    }
476    if let Some(history) = context.get("message_history").and_then(Value::as_array) {
477        for entry in history {
478            let Some(entry) = entry.as_object() else {
479                continue;
480            };
481            let role = role_from_value(entry.get("role")).or_else(|| {
482                match string(entry, &["kind"]).as_deref() {
483                    Some("request") => Some(HistoryRole::User),
484                    Some("response") => Some(HistoryRole::Assistant),
485                    _ => None,
486                }
487            });
488            let Some(role) = role else {
489                continue;
490            };
491            if let Some(text) = rovo_parts_text(entry.get("parts"), role) {
492                session.push(role, Some(text));
493            }
494        }
495    }
496    Ok(session.finish())
497}
498
499fn parse_message_graph(document: &HistoryDocument) -> Result<HistorySession, HistoryAdapterError> {
500    let mut session = SessionBuilder::new(document.session_id_hint.trim().to_owned());
501    for record in ndjson_records(&document.transcript) {
502        match record.get("type").and_then(Value::as_str) {
503            Some("session") => {
504                if let Some(id) = string(&record, &["id"]) {
505                    session.session_id = select_session_id(Some(id), &session.session_id)?;
506                }
507                session.cwd = string(&record, &["cwd"]).or(session.cwd);
508            }
509            Some("model_change") => {
510                session.model = string(&record, &["modelId", "model"]).or(session.model);
511            }
512            Some("message") => {
513                let Some(message) = record.get("message").and_then(Value::as_object) else {
514                    continue;
515                };
516                let Some(role) = role_from_value(message.get("role")) else {
517                    continue;
518                };
519                if role == HistoryRole::Assistant {
520                    session.model = string(message, &["model"]).or(session.model);
521                    if let Some(usage) = message.get("usage") {
522                        session.add_total_tokens(token_total(usage));
523                    }
524                }
525                session.push(role, message.get("content").and_then(content_text));
526            }
527            _ => {}
528        }
529    }
530    Ok(session.finish())
531}
532
533struct SessionBuilder {
534    session_id: String,
535    title: Option<String>,
536    cwd: Option<String>,
537    model: Option<String>,
538    message_count: u64,
539    completed_turn_count: Option<u64>,
540    total_tokens: u64,
541    total_tokens_observed: bool,
542    messages: VecDeque<HistoryMessage>,
543}
544
545impl SessionBuilder {
546    fn new(session_id: String) -> Self {
547        Self {
548            session_id,
549            title: None,
550            cwd: None,
551            model: None,
552            message_count: 0,
553            completed_turn_count: None,
554            total_tokens: 0,
555            total_tokens_observed: false,
556            messages: VecDeque::new(),
557        }
558    }
559
560    fn add_total_tokens(&mut self, observed: Option<u64>) {
561        let Some(total) = observed else { return; };
562        self.total_tokens_observed = true;
563        self.total_tokens = self.total_tokens.saturating_add(total);
564    }
565
566    fn push(&mut self, role: HistoryRole, text: Option<String>) {
567        self.message_count = self.message_count.saturating_add(1);
568        let text = text.and_then(normalize_message);
569        if role == HistoryRole::User && self.title.is_none() {
570            self.title = text.clone().and_then(normalize_title);
571        }
572        if let Some(text) = text {
573            if self.messages.len() == HISTORY_STORED_MESSAGES_MAX {
574                self.messages.pop_front();
575            }
576            self.messages.push_back(HistoryMessage { role, text });
577        }
578    }
579
580    fn finish(self) -> HistorySession {
581        HistorySession {
582            session_id: self.session_id,
583            title: self.title,
584            cwd: self.cwd,
585            model: self.model,
586            message_count: self.message_count,
587            completed_turn_count: self.completed_turn_count,
588            total_tokens: self.total_tokens,
589            total_tokens_observed: self.total_tokens_observed,
590            messages: self.messages.into_iter().collect(),
591        }
592    }
593}
594
595fn metadata_object(document: &HistoryDocument) -> Result<Map<String, Value>, HistoryAdapterError> {
596    let metadata = document
597        .metadata_json
598        .as_deref()
599        .ok_or(HistoryAdapterError::MissingMetadata)?;
600    serde_json::from_str::<Value>(metadata)
601        .ok()
602        .and_then(|value| value.as_object().cloned())
603        .ok_or(HistoryAdapterError::InvalidMetadata)
604}
605
606fn transcript_object(
607    document: &HistoryDocument,
608    empty_is_object: bool,
609) -> Result<Map<String, Value>, HistoryAdapterError> {
610    if empty_is_object && document.transcript.trim().is_empty() {
611        return Ok(Map::new());
612    }
613    serde_json::from_str::<Value>(&document.transcript)
614        .ok()
615        .and_then(|value| value.as_object().cloned())
616        .ok_or(HistoryAdapterError::InvalidTranscript)
617}
618
619fn ndjson_records(content: &str) -> impl Iterator<Item = Map<String, Value>> + '_ {
620    content.lines().filter_map(|line| {
621        serde_json::from_str::<Value>(line.trim())
622            .ok()
623            .and_then(|value| value.as_object().cloned())
624    })
625}
626
627fn select_session_id(
628    candidate: Option<String>,
629    fallback: &str,
630) -> Result<String, HistoryAdapterError> {
631    let value = candidate
632        .as_deref()
633        .map(str::trim)
634        .filter(|value| !value.is_empty())
635        .unwrap_or_else(|| fallback.trim());
636    validate_session_id(value)?;
637    Ok(value.to_owned())
638}
639
640fn validate_session_id(value: &str) -> Result<(), HistoryAdapterError> {
641    let value = value.trim();
642    if value.is_empty()
643        || value.len() > 512
644        || value.starts_with('-')
645        || value.chars().any(char::is_control)
646    {
647        return Err(HistoryAdapterError::InvalidSessionId);
648    }
649    Ok(())
650}
651
652fn role_from_value(value: Option<&Value>) -> Option<HistoryRole> {
653    match value.and_then(Value::as_str) {
654        Some("user") => Some(HistoryRole::User),
655        Some("assistant") => Some(HistoryRole::Assistant),
656        _ => None,
657    }
658}
659
660fn string(record: &Map<String, Value>, keys: &[&str]) -> Option<String> {
661    keys.iter().find_map(|key| {
662        record
663            .get(*key)
664            .and_then(Value::as_str)
665            .map(str::trim)
666            .filter(|value| !value.is_empty())
667            .map(ToOwned::to_owned)
668    })
669}
670
671fn u64_value(record: &Map<String, Value>, keys: &[&str]) -> Option<u64> {
672    keys.iter().find_map(|key| {
673        record.get(*key).and_then(|value| {
674            value
675                .as_u64()
676                .or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok()))
677        })
678    })
679}
680
681fn sum_named_numbers(record: &Map<String, Value>, keys: &[&str]) -> Option<u64> {
682    let mut observed = false;
683    let total = keys
684        .iter()
685        .filter_map(|key| {
686            let value = u64_value(record, &[*key]);
687            observed |= value.is_some();
688            value
689        })
690        .fold(0, u64::saturating_add);
691    observed.then_some(total)
692}
693
694fn claude_usage_total(value: &Value) -> Option<u64> {
695    let Some(usage) = value.as_object() else {
696        return None;
697    };
698    sum_named_numbers(
699        usage,
700        &[
701            "input_tokens",
702            "output_tokens",
703            "cache_read_input_tokens",
704            "cache_creation_input_tokens",
705        ],
706    )
707}
708
709fn claude_completed_turn_message_id(
710    record: &Map<String, Value>,
711    message: &Map<String, Value>,
712) -> Option<String> {
713    if record.get("isMeta").and_then(Value::as_bool) == Some(true)
714        || message.get("stop_reason").and_then(Value::as_str) != Some("end_turn")
715    {
716        return None;
717    }
718    let message_id = message.get("id")?.as_str()?.trim();
719    if message_id.is_empty()
720        || message_id.len() > HISTORY_PROVIDER_MESSAGE_ID_MAX_BYTES
721        || !message_id
722            .bytes()
723            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
724    {
725        return None;
726    }
727    let has_visible_text = message
728        .get("content")
729        .and_then(Value::as_array)
730        .is_some_and(|content| {
731            content.iter().any(|block| {
732                let Some(block) = block.as_object() else {
733                    return false;
734                };
735                block.get("type").and_then(Value::as_str) == Some("text")
736                    && block
737                        .get("text")
738                        .and_then(Value::as_str)
739                        .and_then(|text| normalize_message(text.to_owned()))
740                        .is_some()
741            })
742        });
743    has_visible_text.then(|| message_id.to_owned())
744}
745
746#[derive(Clone, Copy, Debug, Default)]
747struct CodexUsage {
748    input_tokens: u64,
749    output_tokens: u64,
750    total_tokens: u64,
751}
752
753impl CodexUsage {
754    fn add(self, other: Self) -> Self {
755        Self {
756            input_tokens: self.input_tokens.saturating_add(other.input_tokens),
757            output_tokens: self.output_tokens.saturating_add(other.output_tokens),
758            total_tokens: self.total_tokens.saturating_add(other.total_tokens),
759        }
760    }
761}
762
763fn normalize_codex_usage(value: &Value) -> Option<CodexUsage> {
764    let usage = value.as_object()?;
765    if u64_value(usage, &["input_tokens", "output_tokens", "total_tokens"]).is_none() {
766        return None;
767    }
768    let input_tokens = u64_value(usage, &["input_tokens"]).unwrap_or(0);
769    let output_tokens = u64_value(usage, &["output_tokens"]).unwrap_or(0);
770    Some(CodexUsage {
771        input_tokens,
772        output_tokens,
773        total_tokens: u64_value(usage, &["total_tokens"])
774            .filter(|value| *value > 0)
775            .unwrap_or_else(|| input_tokens.saturating_add(output_tokens)),
776    })
777}
778
779fn model_from_nested_record(record: &Map<String, Value>) -> Option<String> {
780    string(record, &["model", "model_name"])
781        .or_else(|| {
782            record
783                .get("metadata")
784                .and_then(Value::as_object)
785                .and_then(|metadata| string(metadata, &["model"]))
786        })
787        .or_else(|| {
788            record
789                .get("info")
790                .and_then(Value::as_object)
791                .and_then(|info| string(info, &["model"]))
792        })
793}
794
795fn is_codex_worker_session(payload: &Map<String, Value>) -> bool {
796    if let Some(source) = string(payload, &["thread_source", "threadSource"]) {
797        return !source.eq_ignore_ascii_case("user");
798    }
799    payload
800        .get("source")
801        .and_then(Value::as_object)
802        .and_then(|source| source.get("subagent"))
803        .and_then(Value::as_object)
804        .is_some()
805}
806
807fn is_known_harness_injected_user_turn(text: &str) -> bool {
808    let normalized = text.trim().to_ascii_lowercase();
809    let tag = normalized
810        .strip_prefix('<')
811        .and_then(|value| value.split([' ', '>']).next());
812    let known_tag = tag.is_some_and(|tag| {
813        matches!(
814            tag,
815            "agent-message"
816                | "bash-input"
817                | "bash-stderr"
818                | "bash-stdout"
819                | "command-args"
820                | "command-message"
821                | "command-name"
822                | "cross-session-message"
823                | "fork-boilerplate"
824                | "local-command-caveat"
825                | "local-command-stderr"
826                | "local-command-stdout"
827                | "mcp-polling-update"
828                | "mcp-resource-update"
829                | "system-reminder"
830                | "task-notification"
831                | "teammate-message"
832                | "user-memory-input"
833                | "user-prompt-submit-hook"
834        )
835    });
836    known_tag
837        || [
838            "<channel source=",
839            "[request interrupted",
840            "a message arrived from ",
841            "another claude session sent a message",
842            "no response requested.",
843            "caveat: the messages below were generated by the user while running local commands",
844            "this session is being continued from a previous conversation",
845        ]
846        .iter()
847        .any(|prefix| normalized.starts_with(prefix))
848}
849
850fn token_total(value: &Value) -> Option<u64> {
851    let Some(usage) = value.as_object() else {
852        return None;
853    };
854    if let Some(total) = u64_value(usage, &["total", "totalTokens", "total_tokens"])
855    {
856        return Some(total);
857    }
858    sum_named_numbers(
859        usage,
860        &[
861            "input",
862            "inputTokens",
863            "input_tokens",
864            "output",
865            "outputTokens",
866            "output_tokens",
867            "cacheRead",
868            "cacheReadTokens",
869            "cache_read_input_tokens",
870            "cacheWrite",
871            "cacheWriteTokens",
872            "cache_creation_input_tokens",
873            "cached",
874            "cachedInputTokens",
875            "cached_input_tokens",
876            "reasoning",
877            "reasoningOutputTokens",
878            "reasoning_output_tokens",
879        ],
880    )
881}
882
883fn rovo_parts_text(value: Option<&Value>, role: HistoryRole) -> Option<String> {
884    let parts = value.and_then(Value::as_array)?;
885    let mut text = Vec::new();
886    for part in parts {
887        let Some(part) = part.as_object() else {
888            continue;
889        };
890        let kind = string(part, &["part_kind"]);
891        let accepted = match role {
892            HistoryRole::User => matches!(kind.as_deref(), Some("user-prompt" | "text")),
893            HistoryRole::Assistant => kind.as_deref() == Some("text"),
894        };
895        if accepted {
896            if let Some(value) = string(part, &["content", "text"]) {
897                text.push(value);
898            }
899        }
900    }
901    normalize_message(text.join(" "))
902}
903
904fn grok_content_text(value: &Value) -> Option<String> {
905    let text = content_text(value)?;
906    let lower = text.to_ascii_lowercase();
907    let opener = "<user_query>";
908    let closer = "</user_query>";
909    let Some(start) = lower.find(opener).map(|index| index + opener.len()) else {
910        return Some(text);
911    };
912    let Some(end) = lower[start..].find(closer).map(|index| start + index) else {
913        return Some(text);
914    };
915    normalize_message(text[start..end].to_owned()).or(Some(text))
916}
917
918fn content_text(value: &Value) -> Option<String> {
919    content_text_at_depth(value, 0).and_then(normalize_message)
920}
921
922fn claude_visible_message_text(value: &Value) -> Option<String> {
923    match value {
924        Value::String(text) => normalize_message(text.clone()),
925        Value::Array(parts) => {
926            let visible = parts
927                .iter()
928                .filter_map(Value::as_object)
929                .filter(|part| part.get("type").and_then(Value::as_str) == Some("text"))
930                .filter_map(|part| part.get("text").and_then(Value::as_str))
931                .collect::<Vec<_>>()
932                .join(" ");
933            normalize_message(visible)
934        }
935        _ => None,
936    }
937}
938
939fn content_text_at_depth(value: &Value, depth: usize) -> Option<String> {
940    if depth > 4 {
941        return None;
942    }
943    match value {
944        Value::String(text) => Some(text.clone()),
945        Value::Array(values) => {
946            let parts = values
947                .iter()
948                .filter_map(|value| content_text_at_depth(value, depth + 1))
949                .collect::<Vec<_>>();
950            (!parts.is_empty()).then(|| parts.join(" "))
951        }
952        Value::Object(record) => ["text", "content", "message"].iter().find_map(|key| {
953            record
954                .get(*key)
955                .and_then(|value| content_text_at_depth(value, depth + 1))
956        }),
957        _ => None,
958    }
959}
960
961fn flush_assistant(session: &mut SessionBuilder, parts: &mut Vec<String>) {
962    if parts.is_empty() {
963        return;
964    }
965    let text = parts.join("");
966    parts.clear();
967    session.push(HistoryRole::Assistant, Some(text));
968}
969
970fn normalize_message(value: String) -> Option<String> {
971    let visible = strip_hidden_context_blocks(value);
972    let normalized = visible.split_whitespace().collect::<Vec<_>>().join(" ");
973    let lower = normalized.to_ascii_lowercase();
974    if lower.starts_with("# agents.md instructions") || lower.starts_with("<instructions>") {
975        return None;
976    }
977    (!normalized.is_empty()).then(|| normalized.chars().take(HISTORY_MESSAGE_MAX_CHARS).collect())
978}
979
980fn strip_hidden_context_blocks(mut value: String) -> String {
981    const HIDDEN: [(&str, &str); 3] = [
982        ("system-reminder", "</system-reminder>"),
983        ("codex_internal_context", "</codex_internal_context>"),
984        ("goal_context", "</goal_context>"),
985    ];
986    loop {
987        let lower = value.to_ascii_lowercase();
988        let next = HIDDEN
989            .iter()
990            .filter_map(|(name, close)| find_open_tag(&lower, name).map(|start| (start, *close)))
991            .min_by_key(|(start, _)| *start);
992        let Some((start, close)) = next else {
993            return value;
994        };
995        let Some(open_end) = lower[start..].find('>').map(|offset| start + offset + 1) else {
996            value.truncate(start);
997            return value;
998        };
999        let Some(close_start) = lower[open_end..]
1000            .find(close)
1001            .map(|offset| open_end + offset)
1002        else {
1003            value.truncate(start);
1004            return value;
1005        };
1006        value.replace_range(start..close_start + close.len(), " ");
1007    }
1008}
1009
1010fn find_open_tag(value: &str, name: &str) -> Option<usize> {
1011    let needle = format!("<{name}");
1012    let mut from = 0;
1013    while let Some(offset) = value[from..].find(&needle) {
1014        let start = from + offset;
1015        let boundary = value.as_bytes().get(start + needle.len()).copied();
1016        if boundary.is_none_or(|byte| byte == b'>' || byte.is_ascii_whitespace()) {
1017            return Some(start);
1018        }
1019        from = start + needle.len();
1020    }
1021    None
1022}
1023
1024fn normalize_title(value: String) -> Option<String> {
1025    let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
1026    (!normalized.is_empty()).then(|| normalized.chars().take(HISTORY_TITLE_MAX_CHARS).collect())
1027}
1028
1029#[derive(Clone, Debug, Error, Eq, PartialEq)]
1030pub enum HistoryAdapterError {
1031    #[error("history metadata is required for this provider")]
1032    MissingMetadata,
1033    #[error("history metadata is not a JSON object")]
1034    InvalidMetadata,
1035    #[error("history metadata exceeds the supported bound")]
1036    MetadataTooLarge,
1037    #[error("history transcript exceeds the supported bound")]
1038    TranscriptTooLarge,
1039    #[error("history transcript is not the required JSON object")]
1040    InvalidTranscript,
1041    #[error("history session ID is empty, unsafe, or too large")]
1042    InvalidSessionId,
1043    #[error("provider history belongs to an internal worker session")]
1044    ExcludedProviderSession,
1045    #[error("history adapter is unavailable for {0}")]
1046    UnsupportedAdapter(String),
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use super::*;
1052
1053    fn id(value: &str) -> AdapterId {
1054        AdapterId::new(value).unwrap()
1055    }
1056
1057    #[test]
1058    fn parses_grok_summary_and_user_query_envelope() {
1059        let session = parse_history(
1060            &id("grok"),
1061            &HistoryDocument {
1062                session_id_hint: "fallback".to_owned(),
1063                metadata_json: Some(
1064                    r#"{"info":{"id":"g1","cwd":"/repo"},"current_model_id":"grok-4","num_chat_messages":2}"#
1065                        .to_owned(),
1066                ),
1067                transcript: concat!(
1068                    r#"{"type":"user","content":"prefix <user_query>fix tests</user_query> suffix"}"#,
1069                    "\n",
1070                    r#"{"type":"assistant","content":"done"}"#
1071                )
1072                .to_owned(),
1073            },
1074        )
1075        .unwrap();
1076        assert_eq!(session.session_id, "g1");
1077        assert_eq!(session.title.as_deref(), Some("fix tests"));
1078        assert_eq!(session.model.as_deref(), Some("grok-4"));
1079        assert_eq!(session.message_count, 2);
1080    }
1081
1082    #[test]
1083    fn parses_claude_titles_usage_and_injected_user_turns() {
1084        let transcript = [
1085            r#"{"type":"user","sessionId":"claude-1","cwd":"/repo","isMeta":true,"message":{"content":"<system-reminder>internal</system-reminder>"}}"#,
1086            r#"{"type":"user","sessionId":"claude-1","message":{"content":"fix the tests"}}"#,
1087            r#"{"type":"assistant","sessionId":"claude-1","message":{"model":"claude-sonnet","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":2,"output_tokens":3,"cache_read_input_tokens":4,"cache_creation_input_tokens":5}}}"#,
1088            r#"{"type":"ai-title","sessionId":"claude-1","aiTitle":"Generated title"}"#,
1089            r#"{"type":"ai-title","sessionId":"claude-1","aiTitle":"   "}"#,
1090        ]
1091        .join("\n");
1092        let session = parse_history(
1093            &id("claude-code"),
1094            &HistoryDocument {
1095                session_id_hint: "fallback".to_owned(),
1096                metadata_json: None,
1097                transcript,
1098            },
1099        )
1100        .unwrap();
1101        assert_eq!(session.session_id, "claude-1");
1102        assert_eq!(session.title.as_deref(), Some("Generated title"));
1103        assert_eq!(session.cwd.as_deref(), Some("/repo"));
1104        assert_eq!(session.model.as_deref(), Some("claude-sonnet"));
1105        assert_eq!(session.total_tokens, 14);
1106        assert!(session.total_tokens_observed);
1107        assert_eq!(session.message_count, 2);
1108        assert_eq!(session.completed_turn_count, Some(0));
1109    }
1110
1111    #[test]
1112    fn history_metrics_distinguish_unknown_token_total_from_zero() {
1113        let without_usage = parse_history(
1114            &id("claude-code"),
1115            &HistoryDocument {
1116                session_id_hint: "without-usage".to_owned(),
1117                metadata_json: None,
1118                transcript: r#"{"type":"assistant","message":{"content":"done"}}"#.to_owned(),
1119            },
1120        )
1121        .unwrap();
1122        assert_eq!(without_usage.total_tokens, 0);
1123        assert!(!without_usage.total_tokens_observed);
1124
1125        let observed_zero = parse_history(
1126            &id("claude-code"),
1127            &HistoryDocument {
1128                session_id_hint: "observed-zero".to_owned(),
1129                metadata_json: None,
1130                transcript: r#"{"type":"assistant","message":{"content":"done","usage":{"input_tokens":0,"output_tokens":0}}}"#.to_owned(),
1131            },
1132        )
1133        .unwrap();
1134        assert_eq!(observed_zero.total_tokens, 0);
1135        assert!(observed_zero.total_tokens_observed);
1136    }
1137
1138    #[test]
1139    fn claude_history_excludes_tool_results_tools_and_thinking_from_messages() {
1140        let transcript = [
1141            r#"{"type":"user","sessionId":"claude-visible","cwd":"/repo","isMeta":true,"message":{"content":"META_TEXT_SENTINEL"}}"#,
1142            r#"{"type":"user","sessionId":"claude-visible","cwd":"/repo","message":{"content":[{"type":"tool_result","content":"TOOL_OUTPUT_SENTINEL"},{"type":"text","text":"visible question"}]}}"#,
1143            r#"{"type":"assistant","sessionId":"claude-visible","message":{"content":[{"type":"thinking","thinking":"THINKING_SENTINEL"},{"type":"tool_use","name":"read","input":{"path":"TOOL_INPUT_SENTINEL"}},{"type":"text","text":"visible answer"}]}}"#,
1144        ]
1145        .join("\n");
1146        let session = parse_history(
1147            &id("claude-code"),
1148            &HistoryDocument {
1149                session_id_hint: "fallback".to_owned(),
1150                metadata_json: None,
1151                transcript,
1152            },
1153        )
1154        .unwrap();
1155
1156        assert_eq!(session.messages.len(), 2);
1157        assert_eq!(session.messages[0].text, "visible question");
1158        assert_eq!(session.messages[1].text, "visible answer");
1159        let projected = session
1160            .messages
1161            .iter()
1162            .map(|message| message.text.as_str())
1163            .collect::<Vec<_>>()
1164            .join(" ");
1165        for sentinel in [
1166            "TOOL_OUTPUT_SENTINEL",
1167            "THINKING_SENTINEL",
1168            "TOOL_INPUT_SENTINEL",
1169            "META_TEXT_SENTINEL",
1170        ] {
1171            assert!(!projected.contains(sentinel));
1172        }
1173    }
1174
1175    #[test]
1176    fn claude_history_excludes_meta_and_harness_user_turns_from_title_and_messages() {
1177        let transcript = [
1178            r#"{"type":"user","sessionId":"claude-private","cwd":"/repo","isMeta":true,"message":{"content":"META_TITLE_SENTINEL"}}"#,
1179            r#"{"type":"user","sessionId":"claude-private","message":{"content":"This session is being continued from a previous conversation HARNESS_TITLE_SENTINEL"}}"#,
1180            r#"{"type":"assistant","sessionId":"claude-private","message":{"content":[{"type":"text","text":"visible answer"}]}}"#,
1181        ]
1182        .join("\n");
1183        let session = parse_history(
1184            &id("claude-code"),
1185            &HistoryDocument {
1186                session_id_hint: "fallback".to_owned(),
1187                metadata_json: None,
1188                transcript,
1189            },
1190        )
1191        .unwrap();
1192
1193        assert_eq!(session.title, None);
1194        assert_eq!(session.messages.len(), 1);
1195        assert_eq!(session.messages[0].text, "visible answer");
1196        let projected = format!(
1197            "{} {}",
1198            session.title.as_deref().unwrap_or_default(),
1199            session
1200                .messages
1201                .iter()
1202                .map(|message| message.text.as_str())
1203                .collect::<Vec<_>>()
1204                .join(" ")
1205        );
1206        for sentinel in ["META_TITLE_SENTINEL", "HARNESS_TITLE_SENTINEL"] {
1207            assert!(!projected.contains(sentinel));
1208        }
1209    }
1210
1211    #[test]
1212    fn claude_completed_turn_count_requires_distinct_valid_end_turn_text_messages() {
1213        let oversized_id_record = serde_json::json!({
1214            "type": "assistant",
1215            "message": {
1216                "id": "x".repeat(HISTORY_PROVIDER_MESSAGE_ID_MAX_BYTES + 1),
1217                "stop_reason": "end_turn",
1218                "content": [{"type": "text", "text": "oversized id"}],
1219            },
1220        })
1221        .to_string();
1222        let transcript = [[
1223            r#"{"type":"assistant","message":{"id":"msg-1","stop_reason":"end_turn","content":[{"type":"text","text":"first answer"}]}}"#,
1224            r#"{"type":"assistant","message":{"id":"msg-1","stop_reason":"end_turn","content":[{"type":"text","text":"duplicate answer"}]}}"#,
1225            r#"{"type":"assistant","message":{"id":"msg_2","stop_reason":"end_turn","content":[{"type":"thinking","thinking":"private"},{"type":"tool_use","id":"tool-1","name":"read"},{"type":"text","text":"second answer"}]}}"#,
1226            r#"{"type":"assistant","message":{"id":"msg-tool","stop_reason":"end_turn","content":[{"type":"tool_use","id":"tool-2","name":"write"}]}}"#,
1227            r#"{"type":"assistant","message":{"id":"msg-thinking","stop_reason":"end_turn","content":[{"type":"thinking","thinking":"private"}]}}"#,
1228            r#"{"type":"assistant","message":{"id":"msg-hidden","stop_reason":"end_turn","content":[{"type":"text","text":"<system-reminder>internal</system-reminder>"}]}}"#,
1229            r#"{"type":"assistant","message":{"id":"msg-not-finished","stop_reason":"tool_use","content":[{"type":"text","text":"not finished"}]}}"#,
1230            r#"{"type":"user","message":{"id":"msg-user","stop_reason":"end_turn","content":[{"type":"text","text":"user text"}]}}"#,
1231            r#"{"type":"assistant","isMeta":true,"message":{"id":"msg-meta","stop_reason":"end_turn","content":[{"type":"text","text":"metadata"}]}}"#,
1232            r#"{"type":"assistant","message":{"id":"bad/id","stop_reason":"end_turn","content":[{"type":"text","text":"invalid id"}]}}"#,
1233            r#"{"type":"assistant","message":{"id":42,"stop_reason":"end_turn","content":[{"type":"text","text":"non-string id"}]}}"#,
1234        ]
1235        .join("\n"), oversized_id_record]
1236        .join("\n");
1237
1238        let session = parse_history(
1239            &id("claude-code"),
1240            &HistoryDocument {
1241                session_id_hint: "claude-completed-turns".to_owned(),
1242                metadata_json: None,
1243                transcript,
1244            },
1245        )
1246        .unwrap();
1247
1248        assert_eq!(session.completed_turn_count, Some(2));
1249    }
1250
1251    #[test]
1252    fn parses_codex_cumulative_usage_and_excludes_worker_transcripts() {
1253        let transcript = [
1254            r#"{"type":"session_meta","payload":{"id":"codex-1","thread_source":"user","cwd":"/repo"}}"#,
1255            r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo/new"}}"#,
1256            r#"{"type":"event_msg","payload":{"type":"user_message","message":"fix tests"}}"#,
1257            r#"{"type":"event_msg","payload":{"type":"agent_message","message":"working"}}"#,
1258            r#"{"type":"response_item","payload":{"type":"message","role":"developer","content":"internal"}}"#,
1259            r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":6,"output_tokens":4,"total_tokens":10}}}}"#,
1260            r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":9,"output_tokens":6,"total_tokens":15}}}}"#,
1261        ]
1262        .join("\n");
1263        let session = parse_history(
1264            &id("codex"),
1265            &HistoryDocument {
1266                session_id_hint: "fallback".to_owned(),
1267                metadata_json: Some(r#"{"indexed_title":"Indexed title"}"#.to_owned()),
1268                transcript,
1269            },
1270        )
1271        .unwrap();
1272        assert_eq!(session.session_id, "codex-1");
1273        assert_eq!(session.title.as_deref(), Some("Indexed title"));
1274        assert_eq!(session.cwd.as_deref(), Some("/repo/new"));
1275        assert_eq!(session.model.as_deref(), Some("gpt-5"));
1276        assert_eq!(session.total_tokens, 15);
1277        assert_eq!(session.message_count, 3);
1278        assert_eq!(session.completed_turn_count, None);
1279
1280        let worker = parse_history(
1281            &id("codex"),
1282            &HistoryDocument {
1283                session_id_hint: "worker".to_owned(),
1284                metadata_json: None,
1285                transcript: r#"{"type":"session_meta","payload":{"thread_source":"subagent"}}"#
1286                    .to_owned(),
1287            },
1288        );
1289        assert_eq!(worker, Err(HistoryAdapterError::ExcludedProviderSession));
1290    }
1291
1292    #[test]
1293    fn parses_kimi_wire_chunks_and_excludes_injections() {
1294        let transcript = [
1295            r#"{"type":"config.update","modelAlias":"kimi-k2"}"#,
1296            r#"{"type":"context.append_message","message":{"role":"user","origin":{"kind":"injection"},"content":"ignore"}}"#,
1297            r#"{"type":"context.append_message","message":{"role":"user","origin":{"kind":"user"},"content":"hello"}}"#,
1298            r#"{"type":"context.append_loop_event","event":{"type":"content.part","part":{"type":"text","text":"world"}}}"#,
1299            r#"{"type":"context.append_loop_event","event":{"type":"step.end"}}"#,
1300            r#"{"type":"usage.record","usage":{"inputOther":2,"output":3,"inputCacheRead":4,"inputCacheCreation":5}}"#,
1301        ]
1302        .join("\n");
1303        let session = parse_history(
1304            &id("kimi"),
1305            &HistoryDocument {
1306                session_id_hint: "session_1".to_owned(),
1307                metadata_json: Some(r#"{"lastPrompt":"fallback"}"#.to_owned()),
1308                transcript,
1309            },
1310        )
1311        .unwrap();
1312        assert_eq!(session.message_count, 2);
1313        assert_eq!(session.total_tokens, 14);
1314        assert_eq!(session.model.as_deref(), Some("kimi-k2"));
1315    }
1316
1317    #[test]
1318    fn parses_cursor_ndjson_shape() {
1319        let session = parse_history(
1320            &id("cursor"),
1321            &HistoryDocument {
1322                session_id_hint: "hint".to_owned(),
1323                metadata_json: None,
1324                transcript: concat!(
1325                    r#"{"role":"user","message":{"content":"question"}}"#,
1326                    "\n",
1327                    r#"{"role":"assistant","content":"answer"}"#
1328                )
1329                .to_owned(),
1330            },
1331        )
1332        .unwrap();
1333        assert_eq!(session.session_id, "hint");
1334        assert_eq!(session.message_count, 2);
1335        assert_eq!(session.title.as_deref(), Some("question"));
1336    }
1337
1338    #[test]
1339    fn parses_pi_omp_and_openclaw_message_graphs() {
1340        let transcript = [
1341            r#"{"type":"session","id":"graph-1","cwd":"/repo"}"#,
1342            r#"{"type":"model_change","modelId":"model-a"}"#,
1343            r#"{"type":"message","message":{"role":"user","content":"question"}}"#,
1344            r#"{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"answer"}],"model":"model-b","usage":{"input":2,"output":3,"cacheRead":4}}}"#,
1345        ]
1346        .join("\n");
1347        for adapter in ["pi", "omp", "openclaw"] {
1348            let session = parse_history(
1349                &id(adapter),
1350                &HistoryDocument {
1351                    session_id_hint: "fallback".to_owned(),
1352                    metadata_json: None,
1353                    transcript: transcript.clone(),
1354                },
1355            )
1356            .unwrap();
1357            assert_eq!(session.session_id, "graph-1");
1358            assert_eq!(session.cwd.as_deref(), Some("/repo"));
1359            assert_eq!(session.model.as_deref(), Some("model-b"));
1360            assert_eq!(session.total_tokens, 9);
1361            assert_eq!(session.message_count, 2);
1362        }
1363    }
1364
1365    #[test]
1366    fn parses_rovo_object_contract() {
1367        let rovo = parse_history(
1368            &id("rovo"),
1369            &HistoryDocument {
1370                session_id_hint: "rovo-dir-1".to_owned(),
1371                metadata_json: Some(
1372                    r#"{"title":"Rovo task","workspace_path":"/repo"}"#.to_owned(),
1373                ),
1374                transcript: r#"{"messages":[{"role":"user","content":"question"}],"message_history":[{"kind":"response","parts":[{"part_kind":"text","content":"answer"},{"part_kind":"tool","content":"hidden"}]}]}"#.to_owned(),
1375            },
1376        )
1377        .unwrap();
1378        assert_eq!(rovo.session_id, "rovo-dir-1");
1379        assert_eq!(rovo.title.as_deref(), Some("Rovo task"));
1380        assert_eq!(rovo.message_count, 2);
1381        assert_eq!(rovo.messages[1].text, "answer");
1382    }
1383
1384    #[test]
1385    fn malformed_lines_are_skipped_but_bounds_are_enforced() {
1386        let session = parse_history(
1387            &id("cursor"),
1388            &HistoryDocument {
1389                session_id_hint: "c1".to_owned(),
1390                metadata_json: None,
1391                transcript: "not-json\n{\"role\":\"user\",\"content\":\"ok\"}".to_owned(),
1392            },
1393        )
1394        .unwrap();
1395        assert_eq!(session.message_count, 1);
1396
1397        let oversized = HistoryDocument {
1398            session_id_hint: "c1".to_owned(),
1399            metadata_json: None,
1400            transcript: "x".repeat(HISTORY_DOCUMENT_MAX_BYTES + 1),
1401        };
1402        assert_eq!(
1403            parse_history(&id("cursor"), &oversized),
1404            Err(HistoryAdapterError::TranscriptTooLarge)
1405        );
1406    }
1407
1408    #[test]
1409    fn retained_history_is_utf8_safe_and_bounded_without_losing_total_count() {
1410        let long_text = format!("привет{}", "界".repeat(HISTORY_MESSAGE_MAX_CHARS));
1411        let transcript = (0..=HISTORY_STORED_MESSAGES_MAX)
1412            .map(|index| {
1413                serde_json::json!({
1414                    "role": if index % 2 == 0 { "user" } else { "assistant" },
1415                    "content": long_text
1416                })
1417                .to_string()
1418            })
1419            .collect::<Vec<_>>()
1420            .join("\n");
1421        let session = parse_history(
1422            &id("cursor"),
1423            &HistoryDocument {
1424                session_id_hint: "c1".to_owned(),
1425                metadata_json: None,
1426                transcript,
1427            },
1428        )
1429        .unwrap();
1430        assert_eq!(
1431            session.message_count,
1432            u64::try_from(HISTORY_STORED_MESSAGES_MAX + 1).unwrap()
1433        );
1434        assert_eq!(session.messages.len(), HISTORY_STORED_MESSAGES_MAX);
1435        assert_eq!(
1436            session.messages[0].text.chars().count(),
1437            HISTORY_MESSAGE_MAX_CHARS
1438        );
1439        assert!(session.messages[0].text.starts_with("привет"));
1440    }
1441
1442    #[test]
1443    fn retained_history_keeps_the_most_recent_tail_in_chronological_order() {
1444        let transcript = (0..HISTORY_STORED_MESSAGES_MAX + 2)
1445            .map(|index| {
1446                serde_json::json!({
1447                    "role": if index % 2 == 0 { "user" } else { "assistant" },
1448                    "content": format!("message-{index}")
1449                })
1450                .to_string()
1451            })
1452            .collect::<Vec<_>>()
1453            .join("\n");
1454        let session = parse_history(
1455            &id("cursor"),
1456            &HistoryDocument {
1457                session_id_hint: "cursor-tail".to_owned(),
1458                metadata_json: None,
1459                transcript,
1460            },
1461        )
1462        .unwrap();
1463
1464        assert_eq!(
1465            session.message_count,
1466            u64::try_from(HISTORY_STORED_MESSAGES_MAX + 2).unwrap()
1467        );
1468        assert_eq!(session.messages.len(), HISTORY_STORED_MESSAGES_MAX);
1469        assert_eq!(session.messages[0].text, "message-2");
1470        assert_eq!(
1471            session.messages.last().unwrap().text,
1472            format!("message-{}", HISTORY_STORED_MESSAGES_MAX + 1)
1473        );
1474    }
1475
1476    #[test]
1477    fn split_metadata_contracts_reject_missing_or_invalid_json() {
1478        let missing = HistoryDocument {
1479            session_id_hint: "g1".to_owned(),
1480            metadata_json: None,
1481            transcript: String::new(),
1482        };
1483        assert_eq!(
1484            parse_history(&id("grok"), &missing),
1485            Err(HistoryAdapterError::MissingMetadata)
1486        );
1487
1488        let invalid = HistoryDocument {
1489            session_id_hint: "k1".to_owned(),
1490            metadata_json: Some("[]".to_owned()),
1491            transcript: String::new(),
1492        };
1493        assert_eq!(
1494            parse_history(&id("kimi"), &invalid),
1495            Err(HistoryAdapterError::InvalidMetadata)
1496        );
1497    }
1498
1499    #[test]
1500    fn source_contracts_keep_related_reads_in_the_effect_owning_shell() {
1501        let rovo = history_source_variants(&id("rovo")).unwrap();
1502        assert_eq!(rovo.len(), 1);
1503        assert!(rovo.iter().any(|variant| {
1504            variant.layout == HistorySourceLayout::MetadataJsonWithSiblingJson
1505                && variant.requires_sibling_reads
1506        }));
1507
1508        let codex = history_source_variants(&id("codex")).unwrap();
1509        assert_eq!(codex.len(), 1);
1510        assert!(codex[0].requires_auxiliary_index);
1511        assert!(matches!(
1512            history_source_variants(&id("unknown")),
1513            Err(HistoryAdapterError::UnsupportedAdapter(_))
1514        ));
1515    }
1516
1517    #[test]
1518    fn hidden_harness_context_is_not_retained_as_history_text() {
1519        let session = parse_history(
1520            &id("cursor"),
1521            &HistoryDocument {
1522                session_id_hint: "cursor-1".to_owned(),
1523                metadata_json: None,
1524                transcript: [
1525                    r#"{"role":"user","content":"<system-reminder secret=\"x\">internal</system-reminder> real request"}"#,
1526                    r#"{"role":"assistant","content":"<goal_context>private</goal_context>answer"}"#,
1527                ]
1528                .join("\n"),
1529            },
1530        )
1531        .unwrap();
1532        assert_eq!(session.messages[0].text, "real request");
1533        assert_eq!(session.messages[1].text, "answer");
1534    }
1535}