1use std::collections::HashMap;
62use std::path::{Path, PathBuf};
63
64use async_stream::stream;
65use chrono::{DateTime, Utc};
66use rusqlite::{Connection, OptionalExtension};
67use serde_json::{Map, Value, json};
68use tokio::sync::mpsc;
69
70use crate::{
71 sessions::{IngestEvent, MessageWithParts, SessionWithMessages},
72 wire::{FileData, Message, Part, PartKind, Provenance, ProviderOptions, Session},
73};
74
75use super::{
76 Adapter, AdapterError, AdapterFactory, AdapterYield, AdapterYieldStream, DiscoverFuture, Env,
77 RestoreFidelity, RestoredFile, SkipOracle, SkipReason, by_timestamp_then_id,
78 extract::{Extracted, extract_compact_repr, extract_raw_record, extract_str, json_or_string},
79 extracted_text, is_session_fresh, jsonl_bytes, part_id, part_ordinal, raw_record,
80 source_options,
81 sqlite::{self, CHANNEL_CAP, ColKind, columns_sql, emit, row_to_json},
82 validate_path_id,
83};
84
85const NAME: &str = "hermes";
86
87const CONTENT_JSON_PREFIX: &str = "\u{0}json:";
91
92const SESSION_COLUMNS: &[(&str, ColKind)] = &[
98 ("id", ColKind::Str),
99 ("source", ColKind::Str),
100 ("user_id", ColKind::Str),
101 ("session_key", ColKind::Str),
102 ("chat_id", ColKind::Str),
103 ("chat_type", ColKind::Str),
104 ("thread_id", ColKind::Str),
105 ("display_name", ColKind::Str),
106 ("origin_json", ColKind::Str),
107 ("model", ColKind::Str),
108 ("model_config", ColKind::Str),
109 ("system_prompt", ColKind::Str),
110 ("parent_session_id", ColKind::Str),
111 ("started_at", ColKind::Real),
112 ("ended_at", ColKind::Real),
113 ("end_reason", ColKind::Str),
114 ("message_count", ColKind::Int),
115 ("tool_call_count", ColKind::Int),
116 ("input_tokens", ColKind::Int),
117 ("output_tokens", ColKind::Int),
118 ("cache_read_tokens", ColKind::Int),
119 ("cache_write_tokens", ColKind::Int),
120 ("reasoning_tokens", ColKind::Int),
121 ("cwd", ColKind::Str),
122 ("git_branch", ColKind::Str),
123 ("git_repo_root", ColKind::Str),
124 ("title", ColKind::Str),
125 ("profile_name", ColKind::Str),
126 ("rewind_count", ColKind::Int),
127 ("archived", ColKind::Int),
128];
129
130const MESSAGE_COLUMNS: &[(&str, ColKind)] = &[
134 ("id", ColKind::Int),
135 ("session_id", ColKind::Str),
136 ("role", ColKind::Str),
137 ("content", ColKind::Str),
138 ("tool_call_id", ColKind::Str),
139 ("tool_calls", ColKind::Str),
140 ("tool_name", ColKind::Str),
141 ("effect_disposition", ColKind::Str),
142 ("timestamp", ColKind::Real),
143 ("token_count", ColKind::Int),
144 ("finish_reason", ColKind::Str),
145 ("reasoning", ColKind::Str),
146 ("reasoning_content", ColKind::Str),
147 ("reasoning_details", ColKind::Str),
148 ("codex_reasoning_items", ColKind::Str),
149 ("codex_message_items", ColKind::Str),
150 ("platform_message_id", ColKind::Str),
151 ("observed", ColKind::Int),
152 ("active", ColKind::Int),
153 ("compacted", ColKind::Int),
154 ("api_content", ColKind::Str),
155];
156
157const MESSAGE_META_COLUMNS: &[&str] = &[
161 "token_count",
162 "finish_reason",
163 "platform_message_id",
164 "observed",
165 "active",
166 "compacted",
167 "effect_disposition",
168 "tool_call_id",
169 "tool_name",
170 "api_content",
171 "reasoning_details",
172 "codex_reasoning_items",
173 "codex_message_items",
174];
175
176const JSON_TEXT_META: &[&str] = &[
177 "reasoning_details",
178 "codex_reasoning_items",
179 "codex_message_items",
180];
181
182pub struct HermesFactory;
185
186impl AdapterFactory for HermesFactory {
187 fn name(&self) -> &'static str {
188 NAME
189 }
190
191 fn open(&self, config: Value) -> Result<Box<dyn Adapter>, AdapterError> {
192 Ok(Box::new(HermesAdapter::from_config(config)?))
193 }
194
195 fn probe_default(&self, env: &Env) -> Option<Value> {
196 let override_dir = std::env::var_os("HERMES_HOME").map(PathBuf::from);
200 resolve_home(&env.home, override_dir.as_deref()).map(|home| json!({ "path": home }))
201 }
202
203 fn serialize(
204 &self,
205 session: &SessionWithMessages,
206 fidelity: RestoreFidelity,
207 ) -> Result<Vec<RestoredFile>, AdapterError> {
208 serialize_session(session, fidelity)
209 }
210}
211
212fn resolve_home(home: &Path, override_dir: Option<&Path>) -> Option<PathBuf> {
215 let candidates = [
216 override_dir.map(Path::to_path_buf),
217 Some(home.join(".hermes")),
218 ];
219 candidates
220 .into_iter()
221 .flatten()
222 .find(|root| root.join("state.db").is_file())
223}
224
225#[derive(Debug, Clone)]
228pub struct HermesAdapter {
229 root: PathBuf,
230}
231
232impl HermesAdapter {
233 pub fn new(root: impl Into<PathBuf>) -> Self {
234 Self { root: root.into() }
235 }
236
237 pub fn from_config(config: Value) -> Result<Self, AdapterError> {
238 Ok(Self {
239 root: super::config_path(NAME, config)?,
240 })
241 }
242}
243
244impl Adapter for HermesAdapter {
245 fn discover(&self) -> DiscoverFuture<'_> {
246 let adapter = self.clone();
247 Box::pin(async move {
248 tokio::task::spawn_blocking(move || {
249 Ok(enumerate_and_peek(&adapter, false).entries.len())
250 })
251 .await
252 .map_err(join_error)?
253 })
254 }
255
256 fn events_with<'a>(&'a self, oracle: &'a dyn SkipOracle) -> AdapterYieldStream<'a> {
257 let adapter = self.clone();
258 Box::pin(stream! {
259 let peek = !oracle.is_empty();
260 let enum_adapter = adapter.clone();
261 let enumerated = tokio::task::spawn_blocking(move || enumerate_and_peek(&enum_adapter, peek)).await;
262 let Enumerated { entries, errors } = match enumerated {
263 Ok(enumerated) => enumerated,
264 Err(join) => { yield Err(join_error(join)); return; }
265 };
266 for error in errors {
269 yield Err(error);
270 }
271
272 let mut survivors = Vec::with_capacity(entries.len());
273 for entry in entries {
274 if is_session_fresh(oracle, &entry.session_id, entry.source_ts) {
275 yield Ok(AdapterYield::Skipped {
276 session_id: Some(entry.session_id),
277 project: None,
278 reason: SkipReason::Fresh,
279 });
280 continue;
281 }
282 survivors.push((entry.db_path, entry.session_id));
283 }
284
285 let (tx, mut rx) = mpsc::channel(CHANNEL_CAP);
286 let handle = tokio::task::spawn_blocking(move || read_survivors(survivors, &tx));
287 while let Some(item) = rx.recv().await {
288 yield item;
289 }
290 if let Err(join) = handle.await {
291 yield Err(join_error(join));
292 }
293 })
294 }
295}
296
297struct HeadEntry {
300 db_path: PathBuf,
301 session_id: String,
302 source_ts: Option<i64>,
303}
304
305struct Enumerated {
306 entries: Vec<HeadEntry>,
307 errors: Vec<AdapterError>,
308}
309
310fn list_dbs(root: &Path) -> Vec<PathBuf> {
313 let mut dbs = Vec::new();
314 let default = root.join("state.db");
315 if default.is_file() {
316 dbs.push(default);
317 }
318 if let Ok(read) = std::fs::read_dir(root.join("profiles")) {
319 let mut profile_dbs: Vec<PathBuf> = read
320 .flatten()
321 .filter(|entry| entry.file_type().map(|t| t.is_dir()).unwrap_or(false))
322 .map(|entry| entry.path().join("state.db"))
323 .filter(|db| db.is_file())
324 .collect();
325 profile_dbs.sort();
326 dbs.extend(profile_dbs);
327 }
328 dbs
329}
330
331fn enumerate_and_peek(adapter: &HermesAdapter, peek: bool) -> Enumerated {
332 let mut entries = Vec::new();
333 let mut errors = Vec::new();
334
335 for db_path in list_dbs(&adapter.root) {
336 let conn = match open_db(&db_path) {
337 Ok(conn) => conn,
338 Err(error) => {
339 tracing::warn!(path = %db_path.display(), %error, "hermes: opening state.db failed");
340 errors.push(error);
341 continue;
342 }
343 };
344 let session_ids = match list_session_ids(&conn, &db_path) {
345 Ok(ids) => ids,
346 Err(error) => {
347 tracing::warn!(path = %db_path.display(), %error, "hermes: listing sessions failed");
348 errors.push(error);
349 continue;
350 }
351 };
352 let watermarks = if peek {
355 session_watermarks(&conn).unwrap_or_default()
356 } else {
357 HashMap::new()
358 };
359 for session_id in session_ids {
360 let source_ts = watermarks.get(&session_id).copied();
361 entries.push(HeadEntry {
362 db_path: db_path.clone(),
363 session_id,
364 source_ts,
365 });
366 }
367 }
368
369 Enumerated { entries, errors }
370}
371
372fn list_session_ids(conn: &Connection, db_path: &Path) -> Result<Vec<String>, AdapterError> {
373 let mut stmt = conn
374 .prepare("SELECT id FROM sessions ORDER BY id")
375 .map_err(|error| db_error(db_path, "prepare session list", &error))?;
376 let rows = stmt
377 .query_map([], |row| row.get::<_, String>(0))
378 .map_err(|error| db_error(db_path, "query session list", &error))?;
379 rows.collect::<rusqlite::Result<Vec<_>>>()
380 .map_err(|error| db_error(db_path, "read session id", &error))
381}
382
383fn session_watermarks(conn: &Connection) -> Option<HashMap<String, i64>> {
386 let mut stmt = conn
387 .prepare("SELECT session_id, MAX(timestamp) FROM messages GROUP BY session_id")
388 .ok()?;
389 let rows = stmt
390 .query_map([], |row| {
391 Ok((row.get::<_, String>(0)?, row.get::<_, Option<f64>>(1)?))
392 })
393 .ok()?;
394 let mut map = HashMap::new();
395 for row in rows.flatten() {
396 if let (session_id, Some(secs)) = row {
397 map.insert(session_id, secs_to_micros(secs));
398 }
399 }
400 Some(map)
401}
402
403fn read_survivors(
406 survivors: Vec<(PathBuf, String)>,
407 tx: &mpsc::Sender<Result<AdapterYield, AdapterError>>,
408) {
409 let mut conns: HashMap<PathBuf, Connection> = HashMap::new();
410 for (db_path, session_id) in survivors {
411 let keep = match connection(&mut conns, &db_path) {
412 Ok(conn) => read_session(conn, &db_path, &session_id, tx),
413 Err(error) => tx.blocking_send(Err(error)).is_ok(),
414 };
415 if !keep {
416 return;
417 }
418 }
419}
420
421fn read_session(
422 conn: &Connection,
423 db_path: &Path,
424 session_id: &str,
425 tx: &mpsc::Sender<Result<AdapterYield, AdapterError>>,
426) -> bool {
427 if let Err(error) = validate_path_id(
430 NAME,
431 "session id",
432 session_id,
433 format!("{}#{session_id}", db_path.display()),
434 ) {
435 return tx.blocking_send(Err(error)).is_ok();
436 }
437
438 let row = match fetch_session_row(conn, session_id) {
439 Ok(Some(row)) => row,
440 Ok(None) => {
441 let error = AdapterError::schema(
442 NAME,
443 session_id.to_owned(),
444 "session row vanished between enumeration and read",
445 );
446 return tx.blocking_send(Err(error)).is_ok();
447 }
448 Err(error) => return tx.blocking_send(Err(error)).is_ok(),
449 };
450
451 let Some(created_at) = row
455 .get("started_at")
456 .and_then(Value::as_f64)
457 .and_then(secs_to_dt)
458 else {
459 let error = AdapterError::schema(
460 NAME,
461 session_id.to_owned(),
462 "session has no parseable started_at timestamp",
463 );
464 return tx.blocking_send(Err(error)).is_ok();
465 };
466
467 let (relation, source_agent) = classify(conn, &row);
468 let session = build_session(session_id, &row, relation, source_agent, created_at);
469 emit!(tx, Ok(AdapterYield::Event(IngestEvent::Session(session))));
470
471 let messages = match fetch_messages(conn, session_id) {
472 Ok(messages) => messages,
473 Err(error) => return tx.blocking_send(Err(error)).is_ok(),
474 };
475 for message_row in messages {
476 match message_events(session_id, &message_row) {
477 Ok(events) => {
478 for event in events {
479 emit!(tx, Ok(AdapterYield::Event(event)));
480 }
481 }
482 Err(error) => emit!(tx, Err(error)),
483 }
484 }
485 true
486}
487
488fn fetch_session_row(conn: &Connection, session_id: &str) -> Result<Option<Value>, AdapterError> {
489 let sql = format!(
490 "SELECT {} FROM sessions WHERE id = ?1",
491 columns_sql(SESSION_COLUMNS)
492 );
493 let mut stmt = conn
494 .prepare_cached(&sql)
495 .map_err(|error| db_error(Path::new("sessions"), "prepare session row", &error))?;
496 stmt.query_row([session_id], |row| row_to_json(row, SESSION_COLUMNS))
497 .optional()
498 .map_err(|error| db_error(Path::new("sessions"), "query session row", &error))
499}
500
501fn fetch_messages(conn: &Connection, session_id: &str) -> Result<Vec<Value>, AdapterError> {
502 let sql = format!(
503 "SELECT {} FROM messages WHERE session_id = ?1 ORDER BY id",
504 columns_sql(MESSAGE_COLUMNS)
505 );
506 let mut stmt = conn
507 .prepare_cached(&sql)
508 .map_err(|error| db_error(Path::new("messages"), "prepare messages", &error))?;
509 let rows = stmt
510 .query_map([session_id], |row| row_to_json(row, MESSAGE_COLUMNS))
511 .map_err(|error| db_error(Path::new("messages"), "query messages", &error))?;
512 rows.collect::<rusqlite::Result<Vec<_>>>()
513 .map_err(|error| db_error(Path::new("messages"), "read message row", &error))
514}
515
516fn parent_end_reason(conn: &Connection, parent_id: &str) -> Option<String> {
519 let mut stmt = conn
520 .prepare_cached("SELECT end_reason FROM sessions WHERE id = ?1")
521 .ok()?;
522 stmt.query_row([parent_id], |row| row.get::<_, Option<String>>(0))
523 .ok()
524 .flatten()
525}
526
527#[derive(Clone, Copy)]
530enum Relation {
531 Compaction,
532 Spawn,
533 Branch,
534}
535
536impl Relation {
537 fn tag(self) -> &'static str {
538 match self {
539 Self::Compaction => "compaction_successor",
540 Self::Spawn => "spawn",
541 Self::Branch => "branch",
542 }
543 }
544}
545
546fn classify(conn: &Connection, row: &Value) -> (Option<Relation>, String) {
554 let source = row.get("source").and_then(Value::as_str);
555 let relation = row
556 .get("parent_session_id")
557 .and_then(Value::as_str)
558 .map(|parent_id| {
559 let parent_end = parent_end_reason(conn, parent_id);
560 let branched_marker = model_config_has(row, "_branched_from");
561 if branched_marker || parent_end.as_deref() == Some("branched") {
562 Relation::Branch
563 } else if parent_end.as_deref() == Some("compression") {
564 Relation::Compaction
565 } else {
566 Relation::Spawn
567 }
568 });
569 let source_agent = if source == Some("cron") {
570 format!("{NAME}/cron")
571 } else if matches!(relation, Some(Relation::Spawn)) {
572 format!("{NAME}/subagent")
573 } else {
574 NAME.to_owned()
575 };
576 (relation, source_agent)
577}
578
579fn model_config_has(row: &Value, key: &str) -> bool {
581 row.get("model_config")
582 .and_then(Value::as_str)
583 .and_then(|text| serde_json::from_str::<Value>(text).ok())
584 .and_then(|cfg| cfg.get(key).cloned())
585 .is_some_and(|value| !value.is_null())
586}
587
588fn build_session(
589 session_id: &str,
590 row: &Value,
591 relation: Option<Relation>,
592 source_agent: String,
593 created_at: DateTime<Utc>,
594) -> Session {
595 let project = session_project(row, session_id);
596 let parent_session_id = row
597 .get("parent_session_id")
598 .and_then(Value::as_str)
599 .map(ToOwned::to_owned);
600
601 let mut hermes = row.as_object().cloned().unwrap_or_default();
602 if let Some(relation) = relation {
603 hermes.insert("relation".to_owned(), json!(relation.tag()));
604 }
605
606 let mut options = source_options(NAME, row);
607 options.insert("hermes".to_owned(), Value::Object(hermes));
608
609 Session {
610 id: session_id.to_owned(),
611 parent_session_id,
612 parent_message_id: None,
613 source_agent,
614 created_at,
615 project,
616 options,
617 }
618}
619
620fn session_project(row: &Value, session_id: &str) -> Extracted<String> {
626 if let Some(key) = extract_str(row, "session_key") {
627 return key;
628 }
629 if let (Some(source), Some(chat_id)) = (
630 row.get("source").and_then(Value::as_str),
631 row.get("chat_id").and_then(Value::as_str),
632 ) {
633 let composite = format!("{source}:{chat_id}");
634 if let Some(project) = extract_str(&json!({ "project": composite }), "project") {
635 return project;
636 }
637 }
638 extract_str(row, "cwd")
639 .or_else(|| extract_str(row, "source"))
640 .unwrap_or_else(|| extract_compact_repr(&Value::String(session_id.to_owned())))
641}
642
643fn message_events(session_id: &str, row: &Value) -> Result<Vec<IngestEvent>, AdapterError> {
646 let Some(message_id_int) = row.get("id").and_then(Value::as_i64) else {
651 return Err(AdapterError::schema(
652 NAME,
653 session_id.to_owned(),
654 "message row has no integer id",
655 ));
656 };
657 let message_id = format!("{session_id}:{message_id_int}");
658 let Some(timestamp) = row
659 .get("timestamp")
660 .and_then(Value::as_f64)
661 .and_then(secs_to_dt)
662 else {
663 return Err(AdapterError::schema(
664 NAME,
665 message_id,
666 "message row has no parseable timestamp",
667 ));
668 };
669 let options = message_options(row, message_id_int);
670 let role = row.get("role").and_then(Value::as_str);
671 let content = row.get("content").and_then(Value::as_str);
672
673 let mut parts = Vec::new();
674 let mut ordinal = 0usize;
675
676 let message = match role {
677 Some("user") => {
678 if let Some(content) = content {
679 content_parts(
680 session_id,
681 &message_id,
682 &mut ordinal,
683 content,
684 Provenance::Conversational,
685 &mut parts,
686 );
687 }
688 Message::User {
689 id: message_id.clone(),
690 session_id: session_id.to_owned(),
691 timestamp,
692 options,
693 }
694 }
695 Some("assistant") => {
696 if let Some(content) = content {
697 content_parts(
698 session_id,
699 &message_id,
700 &mut ordinal,
701 content,
702 Provenance::Conversational,
703 &mut parts,
704 );
705 }
706 for key in ["reasoning", "reasoning_content"] {
707 if let Some(text) = extract_str(row, key) {
708 parts.push(reasoning_part(session_id, &message_id, ordinal, text));
709 ordinal += 1;
710 }
711 }
712 match tool_calls(row) {
713 ToolCalls::Parsed(calls) => {
714 for call in calls {
715 parts.push(tool_call_part(session_id, &message_id, ordinal, &call));
716 ordinal += 1;
717 }
718 }
719 ToolCalls::Corrupt(raw) => {
720 let text = extract_str(&json!({ "content": raw }), "content");
721 parts.push(text_part(
722 session_id,
723 &message_id,
724 ordinal,
725 text,
726 Provenance::Conversational,
727 ));
728 }
729 }
730 Message::Assistant {
731 id: message_id.clone(),
732 session_id: session_id.to_owned(),
733 timestamp,
734 options,
735 }
736 }
737 Some("tool") => {
738 parts.push(tool_result_part(session_id, &message_id, row, content));
739 Message::Tool {
740 id: message_id.clone(),
741 session_id: session_id.to_owned(),
742 timestamp,
743 options,
744 }
745 }
746 _ => Message::System {
750 id: message_id.clone(),
751 session_id: session_id.to_owned(),
752 timestamp,
753 content: content.and_then(decoded_text),
754 options,
755 },
756 };
757
758 let mut events = Vec::with_capacity(parts.len() + 1);
759 events.push(IngestEvent::Message(message));
760 events.extend(parts.into_iter().map(IngestEvent::Part));
761 Ok(events)
762}
763
764fn content_parts(
768 session_id: &str,
769 message_id: &str,
770 ordinal: &mut usize,
771 raw: &str,
772 provenance: Provenance,
773 parts: &mut Vec<Part>,
774) {
775 match decode_content(raw) {
776 Decoded::Text(text) => {
777 parts.push(text_part(
778 session_id,
779 message_id,
780 *ordinal,
781 extract_str(&json!({ "text": text }), "text"),
782 provenance,
783 ));
784 *ordinal += 1;
785 }
786 Decoded::Json(Value::Array(items)) => {
787 for item in items {
788 parts.push(multimodal_part(
789 session_id, message_id, *ordinal, &item, provenance,
790 ));
791 *ordinal += 1;
792 }
793 }
794 Decoded::Json(other) => {
795 parts.push(text_part(
796 session_id,
797 message_id,
798 *ordinal,
799 Some(extract_compact_repr(&other)),
800 provenance,
801 ));
802 *ordinal += 1;
803 }
804 }
805}
806
807enum Decoded {
808 Json(Value),
809 Text(String),
810}
811
812fn decode_content(raw: &str) -> Decoded {
813 match raw.strip_prefix(CONTENT_JSON_PREFIX) {
814 Some(rest) => match serde_json::from_str::<Value>(rest) {
817 Ok(value) => Decoded::Json(value),
818 Err(_) => Decoded::Text(raw.to_owned()),
819 },
820 None => Decoded::Text(raw.to_owned()),
821 }
822}
823
824fn decoded_text(raw: &str) -> Option<Extracted<String>> {
827 let text = match decode_content(raw) {
828 Decoded::Text(text) => text,
829 Decoded::Json(value) => value.to_string(),
830 };
831 extract_str(&json!({ "content": text }), "content")
832}
833
834fn multimodal_part(
835 session_id: &str,
836 message_id: &str,
837 ordinal: usize,
838 item: &Value,
839 provenance: Provenance,
840) -> Part {
841 match item.get("type").and_then(Value::as_str) {
842 Some("text") => text_part(
843 session_id,
844 message_id,
845 ordinal,
846 extract_str(item, "text"),
847 provenance,
848 ),
849 Some("image_url") => {
850 let url = item
851 .get("image_url")
852 .and_then(|inner| inner.get("url"))
853 .and_then(Value::as_str);
854 match url {
855 Some(url) => Part {
856 session_id: session_id.to_owned(),
857 id: part_id(message_id, ordinal),
858 message_id: message_id.to_owned(),
859 ordinal: part_ordinal(ordinal),
860 provenance,
861 options: ProviderOptions::new(),
862 kind: PartKind::File {
863 media_type: None,
864 file_name: None,
865 data: FileData::Url(url.to_owned()),
866 },
867 },
868 None => text_part(
869 session_id,
870 message_id,
871 ordinal,
872 Some(extract_compact_repr(item)),
873 provenance,
874 ),
875 }
876 }
877 _ => text_part(
878 session_id,
879 message_id,
880 ordinal,
881 Some(extract_compact_repr(item)),
882 provenance,
883 ),
884 }
885}
886
887enum ToolCalls {
892 Parsed(Vec<Value>),
893 Corrupt(String),
894}
895
896fn tool_calls(row: &Value) -> ToolCalls {
900 let Some(raw) = row.get("tool_calls").and_then(Value::as_str) else {
901 return ToolCalls::Parsed(Vec::new());
902 };
903 let mut value: Value = match serde_json::from_str(raw) {
904 Ok(value) => value,
905 Err(_) => return ToolCalls::Corrupt(raw.to_owned()),
906 };
907 if let Value::String(inner) = &value
908 && let Ok(parsed) = serde_json::from_str::<Value>(inner)
909 {
910 value = parsed;
911 }
912 match value.as_array() {
913 Some(calls) => ToolCalls::Parsed(calls.clone()),
914 None => ToolCalls::Corrupt(raw.to_owned()),
915 }
916}
917
918fn tool_call_part(session_id: &str, message_id: &str, ordinal: usize, call: &Value) -> Part {
919 let function = call.get("function");
920 let name = function.and_then(|f| extract_str(f, "name"));
921 let params = function
922 .and_then(|f| f.get("arguments"))
923 .map(|arguments| match arguments {
924 Value::String(text) => json_or_string(text),
925 other => other.clone(),
926 })
927 .unwrap_or(Value::Null);
928 Part {
929 session_id: session_id.to_owned(),
930 id: part_id(message_id, ordinal),
931 message_id: message_id.to_owned(),
932 ordinal: part_ordinal(ordinal),
933 provenance: Provenance::Conversational,
934 options: ProviderOptions::new(),
935 kind: PartKind::ToolCall {
936 call_id: extract_str(call, "id"),
937 name,
938 params,
939 provider_executed: false,
940 },
941 }
942}
943
944fn tool_result_part(
945 session_id: &str,
946 message_id: &str,
947 row: &Value,
948 content: Option<&str>,
949) -> Part {
950 let result = match content.map(decode_content) {
951 Some(Decoded::Json(value)) => value,
952 Some(Decoded::Text(text)) => Value::String(text),
953 None => Value::Null,
954 };
955 Part {
956 session_id: session_id.to_owned(),
957 id: part_id(message_id, 0),
958 message_id: message_id.to_owned(),
959 ordinal: 0,
960 provenance: Provenance::Injected,
962 options: ProviderOptions::new(),
963 kind: PartKind::ToolResult {
964 call_id: extract_str(row, "tool_call_id"),
965 name: extract_str(row, "tool_name"),
966 is_failure: false,
967 result,
968 },
969 }
970}
971
972fn reasoning_part(
973 session_id: &str,
974 message_id: &str,
975 ordinal: usize,
976 text: Extracted<String>,
977) -> Part {
978 Part {
979 session_id: session_id.to_owned(),
980 id: part_id(message_id, ordinal),
981 message_id: message_id.to_owned(),
982 ordinal: part_ordinal(ordinal),
983 provenance: Provenance::Conversational,
984 options: ProviderOptions::new(),
985 kind: PartKind::Reasoning { text: Some(text) },
986 }
987}
988
989fn text_part(
990 session_id: &str,
991 message_id: &str,
992 ordinal: usize,
993 text: Option<Extracted<String>>,
994 provenance: Provenance,
995) -> Part {
996 Part {
997 session_id: session_id.to_owned(),
998 id: part_id(message_id, ordinal),
999 message_id: message_id.to_owned(),
1000 ordinal: part_ordinal(ordinal),
1001 provenance,
1002 options: ProviderOptions::new(),
1003 kind: PartKind::Text { text },
1004 }
1005}
1006
1007fn message_options(row: &Value, message_id_int: i64) -> ProviderOptions {
1008 let mut hermes = Map::new();
1009 for key in MESSAGE_META_COLUMNS {
1010 let Some(value) = row.get(*key).filter(|v| !v.is_null()) else {
1011 continue;
1012 };
1013 let stored = if JSON_TEXT_META.contains(key) {
1014 value.as_str().map_or_else(|| value.clone(), json_or_string)
1015 } else {
1016 value.clone()
1017 };
1018 hermes.insert((*key).to_owned(), stored);
1019 }
1020 let mut options = ProviderOptions::new();
1021 if !hermes.is_empty() {
1022 options.insert("hermes".to_owned(), Value::Object(hermes));
1023 }
1024 options.insert(
1025 "source".to_owned(),
1026 json!({
1027 "adapter": NAME,
1028 "id": message_id_int,
1029 "raw_record": extract_raw_record(row),
1030 }),
1031 );
1032 options
1033}
1034
1035fn serialize_session(
1042 session: &SessionWithMessages,
1043 _fidelity: RestoreFidelity,
1044) -> Result<Vec<RestoredFile>, AdapterError> {
1045 let session_row = session
1046 .session
1047 .options
1048 .get("source")
1049 .and_then(|source| source.get("raw_record"))
1050 .cloned()
1051 .or_else(|| session.session.options.get("hermes").cloned())
1052 .unwrap_or(Value::Null);
1053 let mut records = vec![json!({ "table": "sessions", "row": session_row })];
1054
1055 let mut messages: Vec<&MessageWithParts> = session.messages.iter().collect();
1056 messages.sort_by(|left, right| {
1057 message_source_id(left)
1058 .cmp(&message_source_id(right))
1059 .then_with(|| by_timestamp_then_id(left, right))
1060 });
1061 for message in messages {
1062 let row = raw_record(message.message.options()).unwrap_or_else(|| reconstruct_row(message));
1063 records.push(json!({ "table": "messages", "row": row }));
1064 }
1065
1066 Ok(vec![RestoredFile::new(
1067 format!("{}.jsonl", session.session.id),
1068 jsonl_bytes(NAME, &records)?,
1069 RestoreFidelity::Foreign,
1070 )])
1071}
1072
1073fn message_source_id(message: &MessageWithParts) -> i64 {
1077 message
1078 .message
1079 .options()
1080 .get("source")
1081 .and_then(|source| source.get("id"))
1082 .and_then(Value::as_i64)
1083 .unwrap_or(i64::MAX)
1084}
1085
1086fn reconstruct_row(message: &MessageWithParts) -> Value {
1089 let text = message.parts.iter().find_map(|part| match &part.kind {
1090 PartKind::Text { text } => Some(extracted_text(text).to_owned()),
1091 _ => None,
1092 });
1093 json!({
1094 "role": message.message.role().as_str(),
1095 "content": text,
1096 "timestamp": message.message.timestamp().timestamp() as f64,
1097 })
1098}
1099
1100fn secs_to_micros(secs: f64) -> i64 {
1104 (secs * 1_000_000.0).round() as i64
1105}
1106
1107fn secs_to_dt(secs: f64) -> Option<DateTime<Utc>> {
1108 DateTime::from_timestamp_micros(secs_to_micros(secs))
1109}
1110
1111fn open_db(path: &Path) -> Result<Connection, AdapterError> {
1112 sqlite::open_db(NAME, path)
1113}
1114
1115fn connection<'a>(
1116 conns: &'a mut HashMap<PathBuf, Connection>,
1117 path: &Path,
1118) -> Result<&'a Connection, AdapterError> {
1119 sqlite::connection(NAME, conns, path)
1120}
1121
1122fn db_error(path: &Path, op: &str, error: &rusqlite::Error) -> AdapterError {
1123 sqlite::db_error(NAME, path, op, error)
1124}
1125
1126fn join_error(join: tokio::task::JoinError) -> AdapterError {
1127 sqlite::join_error(NAME, join)
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132 #![allow(clippy::expect_used, clippy::unwrap_used)]
1133 use super::*;
1134 use tempfile::TempDir;
1135
1136 const HERMES_SCHEMA: &str = "
1140 CREATE TABLE schema_version (version INTEGER NOT NULL);
1141 CREATE TABLE sessions (
1142 id TEXT PRIMARY KEY,
1143 source TEXT NOT NULL,
1144 user_id TEXT,
1145 session_key TEXT,
1146 chat_id TEXT,
1147 chat_type TEXT,
1148 thread_id TEXT,
1149 display_name TEXT,
1150 origin_json TEXT,
1151 expiry_finalized INTEGER DEFAULT 0,
1152 model TEXT,
1153 model_config TEXT,
1154 system_prompt TEXT,
1155 parent_session_id TEXT,
1156 started_at REAL NOT NULL,
1157 ended_at REAL,
1158 end_reason TEXT,
1159 message_count INTEGER DEFAULT 0,
1160 tool_call_count INTEGER DEFAULT 0,
1161 input_tokens INTEGER DEFAULT 0,
1162 output_tokens INTEGER DEFAULT 0,
1163 cache_read_tokens INTEGER DEFAULT 0,
1164 cache_write_tokens INTEGER DEFAULT 0,
1165 reasoning_tokens INTEGER DEFAULT 0,
1166 cwd TEXT,
1167 git_branch TEXT,
1168 git_repo_root TEXT,
1169 title TEXT,
1170 profile_name TEXT,
1171 rewind_count INTEGER NOT NULL DEFAULT 0,
1172 archived INTEGER NOT NULL DEFAULT 0,
1173 FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
1174 );
1175 CREATE TABLE messages (
1176 id INTEGER PRIMARY KEY AUTOINCREMENT,
1177 session_id TEXT NOT NULL REFERENCES sessions(id),
1178 role TEXT NOT NULL,
1179 content TEXT,
1180 tool_call_id TEXT,
1181 tool_calls TEXT,
1182 tool_name TEXT,
1183 effect_disposition TEXT,
1184 timestamp REAL NOT NULL,
1185 token_count INTEGER,
1186 finish_reason TEXT,
1187 reasoning TEXT,
1188 reasoning_content TEXT,
1189 reasoning_details TEXT,
1190 codex_reasoning_items TEXT,
1191 codex_message_items TEXT,
1192 platform_message_id TEXT,
1193 observed INTEGER DEFAULT 0,
1194 active INTEGER NOT NULL DEFAULT 1,
1195 compacted INTEGER NOT NULL DEFAULT 0,
1196 api_content TEXT
1197 );
1198 CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
1199 ";
1200
1201 fn create_db(path: &Path) -> Connection {
1202 let conn = Connection::open(path).unwrap();
1203 conn.execute_batch(HERMES_SCHEMA).unwrap();
1204 conn.execute("INSERT INTO schema_version (version) VALUES (23)", [])
1205 .unwrap();
1206 conn
1207 }
1208
1209 fn insert_session(conn: &Connection, id: &str, source: &str, started_at: f64) {
1211 conn.execute(
1212 "INSERT INTO sessions (id, source, started_at) VALUES (?1, ?2, ?3)",
1213 rusqlite::params![id, source, started_at],
1214 )
1215 .unwrap();
1216 }
1217
1218 fn events(root: &Path) -> Vec<IngestEvent> {
1219 let adapter = HermesAdapter::new(root);
1220 let (tx, mut rx) = mpsc::channel(1024);
1221 let enumerated = enumerate_and_peek(&adapter, false);
1222 let survivors: Vec<(PathBuf, String)> = enumerated
1223 .entries
1224 .into_iter()
1225 .map(|entry| (entry.db_path, entry.session_id))
1226 .collect();
1227 std::thread::scope(|scope| {
1228 scope.spawn(move || read_survivors(survivors, &tx));
1229 let mut out = Vec::new();
1230 while let Some(item) = rx.blocking_recv() {
1231 match item.unwrap() {
1232 AdapterYield::Event(event) => out.push(event),
1233 other => panic!("unexpected non-event yield: {other:?}"),
1234 }
1235 }
1236 out
1237 })
1238 }
1239
1240 fn only<T>(mut items: Vec<T>, predicate: impl Fn(&T) -> bool) -> T {
1241 let position = items.iter().position(predicate).expect("match present");
1242 items.swap_remove(position)
1243 }
1244
1245 #[test]
1246 fn probe_default_requires_a_home_holding_state_db() {
1247 if std::env::var_os("HERMES_HOME").is_some() {
1248 return; }
1250 let temp = TempDir::new().unwrap();
1251 let env = Env::with_home(temp.path());
1252 assert!(
1253 HermesFactory.probe_default(&env).is_none(),
1254 "an empty home is not a source",
1255 );
1256
1257 let home = temp.path().join(".hermes");
1258 std::fs::create_dir_all(&home).unwrap();
1259 assert!(
1260 HermesFactory.probe_default(&env).is_none(),
1261 "a home without state.db is not a source",
1262 );
1263
1264 create_db(&home.join("state.db"));
1265 let probe = HermesFactory.probe_default(&env);
1266 let got = probe
1267 .as_ref()
1268 .and_then(|value| value.get("path"))
1269 .and_then(Value::as_str);
1270 assert_eq!(
1271 got,
1272 home.to_str(),
1273 "probe returns the home holding state.db"
1274 );
1275 }
1276
1277 #[test]
1278 fn probe_default_honors_hermes_home_override() {
1279 let temp = TempDir::new().unwrap();
1282 let custom = temp.path().join("opt-data");
1283 std::fs::create_dir_all(&custom).unwrap();
1284 let home = temp.path().join(".hermes");
1285 std::fs::create_dir_all(&home).unwrap();
1286 create_db(&home.join("state.db"));
1287
1288 assert_eq!(
1290 resolve_home(temp.path(), Some(&custom)),
1291 Some(home.clone()),
1292 "an override without state.db does not win",
1293 );
1294 create_db(&custom.join("state.db"));
1296 assert_eq!(resolve_home(temp.path(), Some(&custom)), Some(custom));
1297 }
1298
1299 #[test]
1300 fn enumerates_default_and_profile_dbs() {
1301 let temp = TempDir::new().unwrap();
1302 let root = temp.path();
1303 let default = create_db(&root.join("state.db"));
1304 insert_session(&default, "s-default", "cli", 1_700_000_000.0);
1305
1306 let profile_dir = root.join("profiles").join("coder");
1307 std::fs::create_dir_all(&profile_dir).unwrap();
1308 let profile = create_db(&profile_dir.join("state.db"));
1309 insert_session(&profile, "s-profile", "tui", 1_700_000_100.0);
1310
1311 let dbs = list_dbs(root);
1312 assert_eq!(dbs.len(), 2, "default + one profile DB enumerated");
1313
1314 let adapter = HermesAdapter::new(root);
1315 let enumerated = enumerate_and_peek(&adapter, false);
1316 let ids: Vec<&str> = enumerated
1317 .entries
1318 .iter()
1319 .map(|entry| entry.session_id.as_str())
1320 .collect();
1321 assert!(ids.contains(&"s-default"));
1322 assert!(ids.contains(&"s-profile"));
1323 }
1324
1325 #[test]
1326 fn user_and_assistant_messages_map_parts_roles_and_tool_calls() {
1327 let temp = TempDir::new().unwrap();
1328 let db_path = temp.path().join("state.db");
1329 let conn = create_db(&db_path);
1330 insert_session(&conn, "s1", "telegram", 1_700_000_000.0);
1331 conn.execute(
1332 "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','user','hello there', 1700000001.0)",
1333 [],
1334 )
1335 .unwrap();
1336 conn.execute(
1337 "INSERT INTO messages (session_id, role, content, tool_calls, timestamp, reasoning) \
1338 VALUES ('s1','assistant','sure', ?1, 1700000002.0, 'let me think')",
1339 [r#"[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]"#],
1340 )
1341 .unwrap();
1342 conn.execute(
1343 "INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, timestamp) \
1344 VALUES ('s1','tool','result-body','call_1','lookup', 1700000003.0)",
1345 [],
1346 )
1347 .unwrap();
1348 drop(conn);
1349
1350 let all = events(temp.path());
1351 let messages: Vec<&Message> = all
1352 .iter()
1353 .filter_map(|event| match event {
1354 IngestEvent::Message(message) => Some(message),
1355 _ => None,
1356 })
1357 .collect();
1358 assert_eq!(messages.len(), 3);
1359 assert!(matches!(messages[0], Message::User { .. }));
1360 assert!(matches!(messages[1], Message::Assistant { .. }));
1361 assert!(matches!(messages[2], Message::Tool { .. }));
1362 assert_eq!(messages[0].id(), "s1:1", "message id = <session>:<rowid>");
1363
1364 let parts: Vec<&Part> = all
1365 .iter()
1366 .filter_map(|event| match event {
1367 IngestEvent::Part(part) => Some(part),
1368 _ => None,
1369 })
1370 .collect();
1371 let tool_call = parts
1373 .iter()
1374 .find(|part| matches!(part.kind, PartKind::ToolCall { .. }))
1375 .expect("assistant tool_call part");
1376 match &tool_call.kind {
1377 PartKind::ToolCall {
1378 call_id,
1379 name,
1380 params,
1381 ..
1382 } => {
1383 assert_eq!(extracted_text(call_id), "call_1");
1384 assert_eq!(extracted_text(name), "lookup");
1385 assert_eq!(params, &json!({ "q": "x" }));
1386 }
1387 _ => unreachable!(),
1388 }
1389 assert!(
1390 parts
1391 .iter()
1392 .any(|part| matches!(part.kind, PartKind::Reasoning { .. })),
1393 "reasoning column becomes a Reasoning part",
1394 );
1395 let tool_result = parts
1396 .iter()
1397 .find(|part| matches!(part.kind, PartKind::ToolResult { .. }))
1398 .expect("tool role -> ToolResult part");
1399 assert_eq!(
1400 tool_result.provenance,
1401 Provenance::Injected,
1402 "tool output is injected, not conversational",
1403 );
1404 }
1405
1406 #[test]
1407 fn multimodal_sentinel_content_decodes_to_parts() {
1408 let temp = TempDir::new().unwrap();
1409 let db_path = temp.path().join("state.db");
1410 let conn = create_db(&db_path);
1411 insert_session(&conn, "s1", "discord", 1_700_000_000.0);
1412 let payload = format!(
1413 "{CONTENT_JSON_PREFIX}{}",
1414 json!([
1415 {"type": "text", "text": "look at this"},
1416 {"type": "image_url", "image_url": {"url": "https://example.com/a.png"}}
1417 ])
1418 );
1419 conn.execute(
1420 "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','user',?1,1700000001.0)",
1421 [payload],
1422 )
1423 .unwrap();
1424 drop(conn);
1425
1426 let all = events(temp.path());
1427 let parts: Vec<&Part> = all
1428 .iter()
1429 .filter_map(|event| match event {
1430 IngestEvent::Part(part) => Some(part),
1431 _ => None,
1432 })
1433 .collect();
1434 assert_eq!(parts.len(), 2, "text + image parts");
1435 assert!(matches!(parts[0].kind, PartKind::Text { .. }));
1436 match &parts[1].kind {
1437 PartKind::File { data, .. } => {
1438 assert_eq!(data, &FileData::Url("https://example.com/a.png".to_owned()));
1439 }
1440 _ => panic!("second multimodal part is a File"),
1441 }
1442 }
1443
1444 #[test]
1445 fn decode_content_falls_back_to_raw_on_bad_json() {
1446 let raw = format!("{CONTENT_JSON_PREFIX}{{not valid json");
1449 match decode_content(&raw) {
1450 Decoded::Text(text) => assert_eq!(text, raw),
1451 Decoded::Json(_) => panic!("corrupt payload must not parse"),
1452 }
1453 }
1454
1455 #[test]
1456 fn watermark_is_max_timestamp_micros() {
1457 let temp = TempDir::new().unwrap();
1458 let db_path = temp.path().join("state.db");
1459 let conn = create_db(&db_path);
1460 insert_session(&conn, "s1", "cli", 1_700_000_000.0);
1461 for (row, ts) in [("a", 1_700_000_010.5), ("b", 1_700_000_005.0)] {
1462 conn.execute(
1463 "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','user',?1,?2)",
1464 rusqlite::params![row, ts],
1465 )
1466 .unwrap();
1467 }
1468 let watermarks = session_watermarks(&conn).unwrap();
1469 assert_eq!(
1470 watermarks.get("s1").copied(),
1471 Some(secs_to_micros(1_700_000_010.5)),
1472 "watermark is MAX(timestamp), non-monotonic order notwithstanding",
1473 );
1474 }
1475
1476 #[test]
1477 fn lineage_relation_and_source_agent_cover_all_kinds() {
1478 let temp = TempDir::new().unwrap();
1479 let db_path = temp.path().join("state.db");
1480 let conn = create_db(&db_path);
1481 conn.execute(
1483 "INSERT INTO sessions (id, source, started_at, end_reason) VALUES ('p-comp','telegram',1.0,'compression')",
1484 [],
1485 ).unwrap();
1486 conn.execute(
1487 "INSERT INTO sessions (id, source, started_at, end_reason) VALUES ('p-branch','telegram',1.0,'branched')",
1488 [],
1489 ).unwrap();
1490 conn.execute(
1491 "INSERT INTO sessions (id, source, started_at, end_reason) VALUES ('p-spawn','telegram',1.0,'agent_close')",
1492 [],
1493 ).unwrap();
1494 conn.execute(
1496 "INSERT INTO sessions (id, source, started_at, parent_session_id) VALUES ('c-comp','telegram',2.0,'p-comp')",
1497 [],
1498 ).unwrap();
1499 conn.execute(
1500 "INSERT INTO sessions (id, source, started_at, parent_session_id) VALUES ('c-branch','telegram',2.0,'p-branch')",
1501 [],
1502 ).unwrap();
1503 conn.execute(
1504 "INSERT INTO sessions (id, source, started_at, parent_session_id) VALUES ('c-spawn','telegram',2.0,'p-spawn')",
1505 [],
1506 ).unwrap();
1507 conn.execute(
1509 "INSERT INTO sessions (id, source, started_at) VALUES ('c-cron','cron',2.0)",
1510 [],
1511 )
1512 .unwrap();
1513 conn.execute(
1514 "INSERT INTO sessions (id, source, started_at, parent_session_id, model_config) \
1515 VALUES ('c-marker','telegram',2.0,'p-spawn','{\"_branched_from\":\"p-spawn\"}')",
1516 [],
1517 )
1518 .unwrap();
1519
1520 let check = |id: &str| {
1521 let row = fetch_session_row(&conn, id).unwrap().unwrap();
1522 let (relation, agent) = classify(&conn, &row);
1523 (relation.map(Relation::tag), agent)
1524 };
1525 assert_eq!(
1526 check("c-comp"),
1527 (Some("compaction_successor"), "hermes".to_owned())
1528 );
1529 assert_eq!(check("c-branch"), (Some("branch"), "hermes".to_owned()));
1530 assert_eq!(
1531 check("c-spawn"),
1532 (Some("spawn"), "hermes/subagent".to_owned())
1533 );
1534 assert_eq!(check("c-cron"), (None, "hermes/cron".to_owned()));
1535 assert_eq!(
1536 check("c-marker"),
1537 (Some("branch"), "hermes".to_owned()),
1538 "the _branched_from marker classifies a branch even when the parent did not end 'branched'",
1539 );
1540 }
1541
1542 #[test]
1543 fn project_prefers_session_key_then_composite_then_source() {
1544 let with_key =
1545 json!({ "source": "telegram", "session_key": "tg:42:main", "chat_id": "42" });
1546 assert_eq!(&*session_project(&with_key, "s"), "tg:42:main");
1547
1548 let composite = json!({ "source": "discord", "chat_id": "99" });
1549 assert_eq!(&*session_project(&composite, "s"), "discord:99");
1550
1551 let source_only = json!({ "source": "cli" });
1552 assert_eq!(&*session_project(&source_only, "s"), "cli");
1553 }
1554
1555 #[test]
1556 fn rewrite_reinsert_appears_as_new_rows_old_survive() {
1557 let temp = TempDir::new().unwrap();
1560 let db_path = temp.path().join("state.db");
1561 let conn = create_db(&db_path);
1562 insert_session(&conn, "s1", "cli", 1_700_000_000.0);
1563 conn.execute(
1564 "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','user','first',1700000001.0)",
1565 [],
1566 )
1567 .unwrap();
1568 conn.execute("DELETE FROM messages WHERE id = 1", [])
1570 .unwrap();
1571 conn.execute(
1572 "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','user','second',1700000009.0)",
1573 [],
1574 )
1575 .unwrap();
1576 let new_id: i64 = conn
1577 .query_row("SELECT MAX(id) FROM messages", [], |row| row.get(0))
1578 .unwrap();
1579 assert_eq!(new_id, 2, "AUTOINCREMENT does not reuse the deleted id 1");
1580 drop(conn);
1581
1582 let message = only(
1583 events(temp.path())
1584 .into_iter()
1585 .filter_map(|event| match event {
1586 IngestEvent::Message(message) => Some(message),
1587 _ => None,
1588 })
1589 .collect(),
1590 |m| matches!(m, Message::User { .. }),
1591 );
1592 assert_eq!(
1593 message.id(),
1594 "s1:2",
1595 "the surviving row carries the fresh id"
1596 );
1597 }
1598
1599 #[test]
1600 fn foreign_serialize_emits_session_plus_message_rows() {
1601 let temp = TempDir::new().unwrap();
1602 let db_path = temp.path().join("state.db");
1603 let conn = create_db(&db_path);
1604 insert_session(&conn, "s1", "cli", 1_700_000_000.0);
1605 conn.execute(
1606 "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','user','q',1700000001.0)",
1607 [],
1608 )
1609 .unwrap();
1610 conn.execute(
1611 "INSERT INTO messages (session_id, role, content, timestamp) VALUES ('s1','assistant','a',1700000002.0)",
1612 [],
1613 )
1614 .unwrap();
1615 drop(conn);
1616
1617 let all = events(temp.path());
1619 let mut session = None;
1620 let mut messages = Vec::new();
1621 let mut parts: Vec<Part> = Vec::new();
1622 for event in all {
1623 match event {
1624 IngestEvent::Session(s) => session = Some(s),
1625 IngestEvent::Message(m) => messages.push(m),
1626 IngestEvent::Part(p) => parts.push(p),
1627 }
1628 }
1629 let session = session.unwrap();
1630 let with_parts: Vec<MessageWithParts> = messages
1631 .into_iter()
1632 .map(|message| {
1633 let owned: Vec<Part> = parts
1634 .iter()
1635 .filter(|part| part.message_id == message.id())
1636 .cloned()
1637 .collect();
1638 MessageWithParts {
1639 parts: owned,
1640 message,
1641 }
1642 })
1643 .collect();
1644 let swm = SessionWithMessages {
1645 session,
1646 messages: with_parts,
1647 };
1648
1649 let files = serialize_session(&swm, RestoreFidelity::Native).unwrap();
1650 assert_eq!(files.len(), 1);
1651 assert_eq!(
1652 files[0].actual_fidelity,
1653 RestoreFidelity::Foreign,
1654 "native is impossible for hermes; the CLI is told it downgraded",
1655 );
1656 let text = std::str::from_utf8(&files[0].bytes).unwrap();
1657 let lines: Vec<&str> = text.lines().collect();
1658 assert_eq!(lines.len(), 3, "one session row + two message rows");
1659 let first: Value = serde_json::from_str(lines[0]).unwrap();
1660 assert_eq!(first.get("table").and_then(Value::as_str), Some("sessions"));
1661 let second: Value = serde_json::from_str(lines[1]).unwrap();
1662 assert_eq!(
1663 second.get("table").and_then(Value::as_str),
1664 Some("messages")
1665 );
1666 assert_eq!(
1667 second.pointer("/row/content").and_then(Value::as_str),
1668 Some("q"),
1669 "messages restore in source id order",
1670 );
1671 }
1672}