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 pub session_id_hint: String,
18 pub metadata_json: Option<String>,
20 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
70pub 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}