use hh_core::{
bundle, AdapterStatus, AgentKind, ChangeKind, Event, EventKind, FileChange, NewSession,
SessionStatus, Store, Uuid,
};
use proptest::prelude::*;
use std::path::PathBuf;
use tempfile::TempDir;
fn new_session(started_at: i64) -> NewSession {
NewSession {
id: Uuid::now_v7(),
started_at,
agent_kind: AgentKind::Generic,
adapter_status: AdapterStatus::None,
command: vec!["agent".into()],
cwd: PathBuf::from("/tmp/proj"),
hostname: None,
hh_version: "test".into(),
model: None,
git_branch: None,
git_sha: None,
git_dirty: None,
}
}
#[derive(Debug, Clone)]
enum TestEvent {
Text {
kind_idx: u8,
text: String,
},
ToolPair {
call_text: String,
result_text: String,
is_error: bool,
},
FileChange {
path: String,
before: Option<String>,
after: Option<String>,
},
}
fn text_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9 ._-]{0,24}"
}
fn test_event_strategy() -> impl Strategy<Value = TestEvent> {
prop_oneof![
(0u8..4, text_strategy()).prop_map(|(kind_idx, text)| TestEvent::Text { kind_idx, text }),
(text_strategy(), text_strategy(), any::<bool>()).prop_map(
|(call_text, result_text, is_error)| TestEvent::ToolPair {
call_text,
result_text,
is_error,
}
),
(
text_strategy(),
prop::option::of(text_strategy()),
prop::option::of(text_strategy()),
)
.prop_map(|(path, before, after)| TestEvent::FileChange {
path: format!("file-{path}.txt"),
before,
after,
}),
]
}
fn text_kind(idx: u8) -> EventKind {
match idx % 4 {
0 => EventKind::UserMessage,
1 => EventKind::AgentMessage,
2 => EventKind::Thinking,
_ => EventKind::Error,
}
}
fn append_text(writer: &hh_core::EventWriter, sid: &str, ts: i64, kind_idx: u8, text: &str) {
writer
.append_event(Event {
session_id: sid.to_string(),
ts_ms: ts,
kind: text_kind(kind_idx),
step: None,
summary: format!("text: {text}"),
body_json: Some(serde_json::json!({ "text": text })),
blob_hash: None,
blob_size: None,
correlates: None,
})
.unwrap();
}
fn append_tool_pair(
writer: &hh_core::EventWriter,
sid: &str,
ts: i64,
call_text: &str,
result_text: &str,
is_error: bool,
) -> i64 {
let call_id = writer
.append_event(Event {
session_id: sid.to_string(),
ts_ms: ts,
kind: EventKind::ToolCall,
step: None,
summary: "tool_call: Bash".into(),
body_json: Some(
serde_json::json!({ "name": "Bash", "input": { "command": call_text } }),
),
blob_hash: None,
blob_size: None,
correlates: None,
})
.unwrap();
let result_ts = ts + 1;
writer
.append_event(Event {
session_id: sid.to_string(),
ts_ms: result_ts,
kind: EventKind::ToolResult,
step: None,
summary: "tool_result".into(),
body_json: Some(serde_json::json!({ "content": result_text, "is_error": is_error })),
blob_hash: None,
blob_size: None,
correlates: Some(call_id),
})
.unwrap();
result_ts
}
fn append_file_change(
store: &Store,
writer: &hh_core::EventWriter,
sid: &str,
ts: i64,
path: &str,
before: Option<&str>,
after: Option<&str>,
) {
let before_hash = before.map(|c| store.blobs().put(c.as_bytes()).unwrap().hash);
let after_out = after.map(|c| store.blobs().put(c.as_bytes()).unwrap());
let change_kind = match (before, after) {
(None, Some(_)) => ChangeKind::Created,
(Some(_), None) => ChangeKind::Deleted,
_ => ChangeKind::Modified,
};
writer
.append_file_change(
Event {
session_id: sid.to_string(),
ts_ms: ts,
kind: EventKind::FileChange,
step: None,
summary: format!("{path} {change_kind}"),
body_json: Some(serde_json::json!({ "path": path })),
blob_hash: after_out.as_ref().map(|o| o.hash.clone()),
blob_size: after_out.as_ref().map(|o| o.size),
correlates: None,
},
FileChange {
event_id: 0,
path: path.to_string(),
change_kind,
before_hash,
after_hash: after_out.map(|o| o.hash),
is_binary: false,
},
)
.unwrap();
}
fn record(store: &Store, events: &[TestEvent]) -> String {
let created = store
.create_session(&new_session(1_700_000_000_000))
.unwrap();
let sid = created.id.clone();
let writer = store.event_writer().unwrap();
let mut ts = 0i64;
for ev in events {
ts += 1;
match ev {
TestEvent::Text { kind_idx, text } => append_text(&writer, &sid, ts, *kind_idx, text),
TestEvent::ToolPair {
call_text,
result_text,
is_error,
} => {
ts = append_tool_pair(&writer, &sid, ts, call_text, result_text, *is_error);
}
TestEvent::FileChange {
path,
before,
after,
} => append_file_change(
store,
&writer,
&sid,
ts,
path,
before.as_deref(),
after.as_deref(),
),
}
}
writer.finish().unwrap();
store.assign_steps(&sid).unwrap();
store
.finalize_session(&sid, ts + 1, Some(0), SessionStatus::Ok)
.unwrap();
sid
}
fn normalize_session(mut v: serde_json::Value) -> serde_json::Value {
if let Some(obj) = v.as_object_mut() {
obj.remove("id");
obj.remove("short_id");
obj.remove("imported_from");
}
v
}
proptest! {
#![proptest_config(ProptestConfig { cases: 24, ..ProptestConfig::default() })]
#[test]
fn export_import_export_round_trips_content(
events in prop::collection::vec(test_event_strategy(), 0..8)
) {
let source_tmp = TempDir::new().unwrap();
let source = Store::open(&source_tmp.path().join("hh.db"), &source_tmp.path().join("blobs")).unwrap();
let sid = record(&source, &events);
let bytes_a = bundle::export(&source, &sid, "test", None).unwrap();
let bundle_a = bundle::parse(&bytes_a).unwrap();
let target_tmp = TempDir::new().unwrap();
let target = Store::open(&target_tmp.path().join("hh.db"), &target_tmp.path().join("blobs")).unwrap();
let created = target.import(&bundle_a).unwrap();
let bytes_b = bundle::export(&target, &created.id, "test", None).unwrap();
let bundle_b = bundle::parse(&bytes_b).unwrap();
prop_assert_eq!(&bundle_a.events, &bundle_b.events);
prop_assert_eq!(&bundle_a.blobs, &bundle_b.blobs);
prop_assert_eq!(
normalize_session(bundle_a.session.clone()),
normalize_session(bundle_b.session.clone())
);
prop_assert_ne!(bundle_a.session["id"].clone(), bundle_b.session["id"].clone());
prop_assert_eq!(&bundle_b.session["imported_from"], &bundle_a.session["id"]);
}
}