use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::bindings::hook::murmur::conversation::read::{Message, MessagePage};
use crate::errors::RuntimeError;
pub(crate) const CONVERSATION_ROOT_DIR: &str = "conversations";
pub(crate) const RECORD_FILE_NAME: &str = "conversation.jsonl";
pub(crate) const RECORD_HEADER_TYPE: &str = "murmur.record";
const RECORD_DIR_MODE: u32 = 0o700;
pub(crate) const RECORD_FILE_MODE: u32 = 0o600;
const CURSOR_PREFIX: &str = "mc_";
const MAX_PAGE_LIMIT: u32 = 100;
const SEGMENT_RULE: &str = "must be a single path segment: no '/', no '.' or '..', not absolute, \
and not starting with a dot";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TruncationMarker {
pub dropped: u64,
pub oldest_surviving_id: String,
pub last_dropped_id: String,
pub at_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RecordHeader {
#[serde(rename = "type")]
pub kind: String,
pub capsule: String,
pub created_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub truncated: Option<TruncationMarker>,
}
pub(crate) fn header_line(header: &RecordHeader) -> Result<String, String> {
let mut line = serde_json::to_string(header)
.map_err(|err| format!("failed to encode the record header: {err}"))?;
line.push('\n');
Ok(line)
}
pub(crate) fn read_header(path: &Path) -> Option<RecordHeader> {
let raw = std::fs::read_to_string(path).ok()?;
parse_header(raw.lines().next()?)
}
pub(crate) fn parse_header(line: &str) -> Option<RecordHeader> {
let header: RecordHeader = serde_json::from_str(line).ok()?;
(header.kind == RECORD_HEADER_TYPE).then_some(header)
}
pub(crate) fn message_id_timestamp_ms(id: &str) -> Option<u64> {
let hex = id.strip_prefix("msg_")?;
if hex.len() < 12 {
return None;
}
u64::from_str_radix(&hex[..12], 16).ok()
}
pub(crate) fn validate_record_segment(field: &str, value: &str) -> Result<(), RuntimeError> {
crate::state_store::validate_store_name(value).map_err(|_| {
RuntimeError::InvalidConversationRecord {
field: field.to_string(),
value: value.to_string(),
message: SEGMENT_RULE.to_string(),
}
})
}
pub(crate) fn resolve_record_name(
context: Option<&murmur_artifact::ContextConfig>,
capsule_name: &str,
) -> Option<String> {
match context {
Some(context) if !context.record => None,
Some(context) => Some(
context
.record_store
.clone()
.unwrap_or_else(|| capsule_name.to_string()),
),
None => Some(capsule_name.to_string()),
}
}
pub(crate) fn record_root(record: &str) -> Result<PathBuf, String> {
validate_record_segment("context.record_store", record).map_err(|err| err.to_string())?;
Ok(crate::state_store::murmur_home_dir()?
.join(CONVERSATION_ROOT_DIR)
.join(record))
}
pub(crate) fn record_file(root: &Path, context_id: &str) -> PathBuf {
root.join(context_id).join(RECORD_FILE_NAME)
}
pub(crate) struct ConversationRecord {
dir: PathBuf,
workdir: PathBuf,
capsule: Option<String>,
header_ensured: bool,
recorded: HashSet<String>,
failure_reported: bool,
malformed_reported: bool,
}
impl ConversationRecord {
pub(crate) fn open(
root: &Path,
context_id: &str,
workdir: &Path,
capsule: Option<&str>,
) -> Option<Self> {
if crate::state_store::validate_store_name(context_id).is_err() {
report(
workdir,
&format!(
"[conversation] context id '{context_id}' {SEGMENT_RULE}; this conversation \
is not recorded"
),
);
return None;
}
Some(Self {
dir: root.join(context_id),
workdir: workdir.to_path_buf(),
capsule: capsule.map(str::to_string),
header_ensured: false,
recorded: HashSet::new(),
failure_reported: false,
malformed_reported: false,
})
}
pub(crate) fn path(&self) -> PathBuf {
self.dir.join(RECORD_FILE_NAME)
}
pub(crate) fn load(&mut self) -> Vec<Value> {
let path = self.path();
let messages = match read_record(&path, &self.workdir, &mut self.malformed_reported) {
Ok(messages) => messages,
Err(reason) => {
report(
&self.workdir,
&format!(
"[conversation] {} could not be read ({reason}); this task starts with no \
prior messages",
path.display()
),
);
Vec::new()
}
};
for message in &messages {
if let Some(id) = crate::agent::message_id(message) {
self.recorded.insert(id.to_string());
}
}
messages
}
pub(crate) fn append(&mut self, messages: &[Value]) {
for message in messages {
let id = crate::agent::message_id(message).map(str::to_string);
if let Some(id) = id.as_deref() {
if self.recorded.contains(id) {
continue;
}
}
if let Err(reason) = self.append_line(message) {
if !self.failure_reported {
self.failure_reported = true;
report(
&self.workdir,
&format!(
"[conversation] {} could not be written ({reason}); this task \
continues unrecorded",
self.path().display()
),
);
}
return;
}
if let Some(id) = id {
self.recorded.insert(id);
}
}
}
fn append_line(&mut self, message: &Value) -> Result<(), String> {
let mut line = serde_json::to_string(message)
.map_err(|err| format!("failed to encode the message: {err}"))?;
line.push('\n');
self.ensure_dirs()?;
self.ensure_header()?;
let mut file = open_record_file(&self.path(), false)?;
file.write_all(line.as_bytes())
.map_err(|err| err.to_string())
}
fn ensure_dirs(&self) -> Result<(), String> {
let mut chain = Vec::new();
let mut dir = Some(self.dir.as_path());
for _ in 0..3 {
if let Some(current) = dir {
chain.push(current);
dir = current.parent();
}
}
if let (Some(owner), Ok(home)) = (dir, crate::state_store::murmur_home_dir()) {
if owner == home {
crate::murmur_home::ensure_murmur_home()?;
}
}
for path in chain.into_iter().rev() {
crate::state_store::ensure_private_dir(path, RECORD_DIR_MODE)
.map_err(|reason| format!("{}: {reason}", path.display()))?;
}
Ok(())
}
fn ensure_header(&mut self) -> Result<(), String> {
if self.header_ensured {
return Ok(());
}
let Some(capsule) = self.capsule.clone() else {
self.header_ensured = true;
return Ok(());
};
let path = self.path();
let existing = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(err) => return Err(format!("{}: {err}", path.display())),
};
if existing.lines().next().and_then(parse_header).is_some() {
self.header_ensured = true;
return Ok(());
}
let header = RecordHeader {
kind: RECORD_HEADER_TYPE.to_string(),
capsule,
created_ms: crate::retention::now_ms(),
truncated: None,
};
let line = header_line(&header)?;
if existing.is_empty() {
open_record_file(&path, true)?
.write_all(line.as_bytes())
.map_err(|err| format!("{}: {err}", path.display()))?;
} else {
let mut contents = line;
contents.push_str(&existing);
crate::retention::StagedRewrite::stage_with_mode(
&path,
contents.as_bytes(),
Some(RECORD_FILE_MODE),
)?
.commit()?;
}
self.header_ensured = true;
Ok(())
}
}
fn open_record_file(path: &Path, truncate: bool) -> Result<std::fs::File, String> {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut options = std::fs::OpenOptions::new();
options.create(true).mode(RECORD_FILE_MODE);
if truncate {
options.write(true).truncate(true);
} else {
options.append(true);
}
let file = options
.open(path)
.map_err(|err| format!("{}: {err}", path.display()))?;
file.set_permissions(std::fs::Permissions::from_mode(RECORD_FILE_MODE))
.map_err(|err| format!("{}: {err}", path.display()))?;
Ok(file)
}
#[derive(Default)]
pub(crate) struct RecordCache {
path: Option<PathBuf>,
len: u64,
messages: Vec<Value>,
malformed_reported: bool,
#[cfg(test)]
reads: u32,
}
impl RecordCache {
fn messages(&mut self, path: &Path, workdir: &Path) -> Result<&[Value], String> {
let len = match std::fs::metadata(path) {
Ok(metadata) => metadata.len(),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => 0,
Err(err) => return Err(err.to_string()),
};
if self.path.as_deref() == Some(path) && self.len == len {
return Ok(&self.messages);
}
self.path = None;
#[cfg(test)]
{
self.reads += 1;
}
self.messages = read_record(path, workdir, &mut self.malformed_reported)?;
self.len = len;
self.path = Some(path.to_path_buf());
Ok(&self.messages)
}
#[cfg(test)]
pub(crate) fn reads(&self) -> u32 {
self.reads
}
}
pub(crate) fn page(
cache: &mut RecordCache,
path: Option<&Path>,
workdir: &Path,
cursor: Option<&str>,
limit: u32,
) -> Result<MessagePage, String> {
let Some(path) = path else {
return Ok(empty_page());
};
let messages = cache
.messages(path, workdir)
.map_err(|reason| format!("unavailable: {reason}"))?;
let end = match cursor {
None => messages.len(),
Some(cursor) => decode_cursor(cursor)
.filter(|end| *end <= messages.len())
.ok_or_else(|| "invalid-cursor".to_string())?,
};
let limit = limit.clamp(1, MAX_PAGE_LIMIT) as usize;
let start = end.saturating_sub(limit);
let mut window: Vec<Value> = messages[start..end].to_vec();
window.reverse();
Ok(MessagePage {
messages: crate::agent::to_wit_messages(&window)
.into_iter()
.map(|message| Message {
role: message.role,
content: message.content,
id: message.id,
source_id: message.source_id,
})
.collect(),
next_cursor: (start > 0).then(|| encode_cursor(start)),
total: u32::try_from(messages.len()).unwrap_or(u32::MAX),
})
}
fn empty_page() -> MessagePage {
MessagePage {
messages: Vec::new(),
next_cursor: None,
total: 0,
}
}
fn read_record(path: &Path, workdir: &Path, reported: &mut bool) -> Result<Vec<Value>, String> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err.to_string()),
};
let mut messages = Vec::new();
for (index, line) in raw.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
match parse_message_line(line) {
Some(message) => messages.push(message),
None => {
if !*reported {
*reported = true;
report(
workdir,
&format!(
"[conversation] {}: line {} is not a message and was skipped",
path.display(),
index + 1
),
);
}
}
}
}
Ok(messages)
}
pub(crate) fn parse_message_line(line: &str) -> Option<Value> {
let value: Value = serde_json::from_str(line).ok()?;
value
.get("role")
.and_then(Value::as_str)
.is_some()
.then_some(value)
}
fn encode_cursor(position: usize) -> String {
format!("{CURSOR_PREFIX}{position}")
}
fn decode_cursor(cursor: &str) -> Option<usize> {
cursor.strip_prefix(CURSOR_PREFIX)?.parse().ok()
}
fn report(workdir: &Path, message: &str) {
eprintln!("[capsule-runtime] {message}");
crate::agent::append_bootstrap_log(workdir, message);
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const CAPSULE: &str = "capsule";
fn message(id: &str, role: &str, text: &str) -> Value {
json!({"role": role, "content": [{"type": "text", "text": text}], "id": id})
}
fn id_at(id: usize) -> String {
format!("msg_{:032x}", id)
}
#[test]
fn record_file_is_held_owner_only_on_append() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/shared");
crate::murmur_home::wide_dir(&root.join("ctx_1"), 0o755);
let path = root.join("ctx_1").join(RECORD_FILE_NAME);
crate::murmur_home::wide_file(&path, "", 0o644);
let mut record = ConversationRecord::open(&root, "ctx_1", workdir.path(), None).unwrap();
record.append(&[message(&id_at(1), "user", "hello")]);
assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
assert_eq!(crate::murmur_home::mode_of(&root.join("ctx_1")), 0o700);
assert_eq!(std::fs::read_to_string(&path).unwrap().lines().count(), 1);
}
#[test]
fn record_file_is_created_owner_only_with_its_header() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/shared");
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
record.append(&[message(&id_at(1), "user", "hello")]);
assert_eq!(crate::murmur_home::mode_of(&record.path()), 0o600);
}
#[test]
fn record_rewrite_keeps_the_file_owner_only() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/shared");
crate::murmur_home::wide_dir(&root.join("ctx_1"), 0o755);
let path = root.join("ctx_1").join(RECORD_FILE_NAME);
let old = serde_json::to_string(&message(&id_at(1), "user", "old")).unwrap();
crate::murmur_home::wide_file(&path, &format!("{old}\n"), 0o644);
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
record.append(&[message(&id_at(2), "user", "new")]);
assert!(read_header(&path).is_some(), "the record was adopted");
assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
}
#[test]
fn record_root_under_a_wide_murmur_home_holds_the_home_owner_only() {
let home = tempfile::tempdir().unwrap();
crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
crate::murmur_home::run_with_home(
"conversation::tests::inner_append_under_scratch_home",
home.path(),
);
assert_eq!(
crate::murmur_home::mode_of(&home.path().join(".murmur")),
0o700
);
let record = home
.path()
.join(".murmur/conversations/shared/ctx_1")
.join(RECORD_FILE_NAME);
assert_eq!(crate::murmur_home::mode_of(&record), 0o600);
}
#[test]
#[ignore = "run by record_root_under_a_wide_murmur_home_holds_the_home_owner_only"]
fn inner_append_under_scratch_home() {
if !crate::murmur_home::in_scratch_home() {
return;
}
let workdir = tempfile::tempdir().unwrap();
let root = record_root("shared").unwrap();
let mut record = ConversationRecord::open(&root, "ctx_1", workdir.path(), None).unwrap();
record.append(&[message(&id_at(1), "user", "hello")]);
assert!(record.path().exists());
}
#[test]
fn the_first_append_writes_a_header_naming_the_capsule() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/shared");
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
record.append(&[message(&id_at(1), "user", "hello")]);
let raw = std::fs::read_to_string(record.path()).unwrap();
let lines: Vec<&str> = raw.lines().collect();
assert_eq!(lines.len(), 2, "a header and one message: {lines:?}");
let header = parse_header(lines[0]).expect("the first line is a header");
assert_eq!(header.kind, RECORD_HEADER_TYPE);
assert_eq!(header.capsule, CAPSULE);
assert!(header.truncated.is_none(), "nothing has been dropped yet");
assert!(header.created_ms > 0);
}
#[test]
fn a_header_line_changes_neither_the_loaded_messages_nor_total() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/c");
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
record.append(&[
message(&id_at(1), "user", "one"),
message(&id_at(2), "assistant", "two"),
]);
let path = record.path();
let mut reader =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
let loaded = reader.load();
assert_eq!(loaded.len(), 2, "the header is not a message: {loaded:?}");
let mut cache = RecordCache::default();
let page = page(&mut cache, Some(&path), workdir.path(), None, 100).unwrap();
assert_eq!(page.total, 2);
assert_eq!(page.messages.len(), 2);
}
#[test]
fn a_headerless_record_is_adopted_on_the_next_append() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/c");
let dir = root.join("ctx_1");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(RECORD_FILE_NAME);
let headerless = format!(
"{}
{}
",
serde_json::to_string(&message(&id_at(1), "user", "one")).unwrap(),
serde_json::to_string(&message(&id_at(2), "assistant", "two")).unwrap()
);
std::fs::write(&path, &headerless).unwrap();
assert!(read_header(&path).is_none(), "unowned before the append");
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
record.append(&[message(&id_at(3), "user", "three")]);
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(read_header(&path).unwrap().capsule, CAPSULE);
assert!(
raw.contains(headerless.trim_end()),
"every line it already held survives byte for byte: {raw}"
);
assert_eq!(raw.lines().count(), 4, "a header and three messages");
}
#[test]
fn an_owned_record_keeps_the_header_it_already_has() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/c");
let dir = root.join("ctx_1");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(RECORD_FILE_NAME);
let header = RecordHeader {
kind: RECORD_HEADER_TYPE.to_string(),
capsule: "someone-else".to_string(),
created_ms: 1_756_400_000_000,
truncated: Some(TruncationMarker {
dropped: 5,
oldest_surviving_id: id_at(6),
last_dropped_id: id_at(5),
at_ms: 1_756_400_000_001,
}),
};
std::fs::write(&path, header_line(&header).unwrap()).unwrap();
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
record.append(&[message(&id_at(7), "user", "seven")]);
assert_eq!(read_header(&path).unwrap(), header);
}
#[test]
fn a_reloaded_message_keeps_its_id_and_is_not_written_twice() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/capsule");
let mut first =
ConversationRecord::open(&root, "ctx_fixed", workdir.path(), Some(CAPSULE)).unwrap();
first.append(&[message(&id_at(1), "user", "hello")]);
let mut second =
ConversationRecord::open(&root, "ctx_fixed", workdir.path(), Some(CAPSULE)).unwrap();
let loaded = second.load();
assert_eq!(loaded.len(), 1);
assert_eq!(
crate::agent::message_id(&loaded[0]),
Some(id_at(1).as_str())
);
second.append(&loaded);
second.append(&[message(&id_at(2), "assistant", "hi")]);
assert_eq!(
second.load().len(),
2,
"a reloaded message is never re-appended"
);
}
#[test]
fn the_directory_chain_is_created_at_0700_on_first_write() {
use std::os::unix::fs::PermissionsExt;
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let conversations = home.path().join("conversations");
let root = conversations.join("capsule");
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
assert!(!conversations.exists(), "opening a record creates nothing");
record.append(&[message(&id_at(1), "user", "hello")]);
for dir in [&conversations, &root, &root.join("ctx_1")] {
let mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode,
0o700,
"{} must be 0700, got {mode:04o}",
dir.display()
);
}
}
#[test]
fn a_context_id_that_is_not_a_segment_records_nothing() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
for context_id in ["../escape", "a/b", "", ".hidden"] {
assert!(
ConversationRecord::open(
&home.path().join("conversations/c"),
context_id,
workdir.path(),
Some(CAPSULE)
)
.is_none(),
"'{context_id}' must not open a record"
);
}
assert!(!home.path().join("conversations").exists());
}
#[test]
fn paging_walks_the_record_newest_first_without_repeating_a_message() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/capsule");
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
let written: Vec<Value> = (1..=5)
.map(|n| message(&id_at(n), "user", &format!("m{n}")))
.collect();
record.append(&written);
let path = record.path();
let mut seen = Vec::new();
let mut cursor: Option<String> = None;
loop {
let page = page(
&mut RecordCache::default(),
Some(&path),
workdir.path(),
cursor.as_deref(),
2,
)
.unwrap();
assert_eq!(page.total, 5);
seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
match page.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
assert_eq!(
seen,
(1..=5).rev().map(id_at).collect::<Vec<_>>(),
"the whole record, newest first, exactly once each"
);
}
#[test]
fn limit_is_clamped_at_both_ends() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/capsule");
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
record.append(
&(1..=120)
.map(|n| message(&id_at(n), "user", "m"))
.collect::<Vec<_>>(),
);
let path = record.path();
assert_eq!(
page(
&mut RecordCache::default(),
Some(&path),
workdir.path(),
None,
0
)
.unwrap()
.messages
.len(),
1
);
assert_eq!(
page(
&mut RecordCache::default(),
Some(&path),
workdir.path(),
None,
u32::MAX
)
.unwrap()
.messages
.len(),
MAX_PAGE_LIMIT as usize
);
}
#[test]
fn a_foreign_cursor_is_refused() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/capsule");
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
record.append(&[message(&id_at(1), "user", "m")]);
let path = record.path();
for cursor in ["7", "mc_", "mc_nine", "mc_9", "cur_1"] {
assert_eq!(
page(
&mut RecordCache::default(),
Some(&path),
workdir.path(),
Some(cursor),
10
)
.err(),
Some("invalid-cursor".to_string()),
"cursor '{cursor}'"
);
}
}
#[test]
fn an_absent_record_is_an_empty_page() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let missing = home
.path()
.join("conversations/capsule/ctx_1/conversation.jsonl");
for path in [None, Some(missing.as_path())] {
let page = page(&mut RecordCache::default(), path, workdir.path(), None, 10).unwrap();
assert!(page.messages.is_empty());
assert_eq!(page.next_cursor, None);
assert_eq!(page.total, 0);
}
}
#[test]
fn a_read_with_no_record_leaves_the_cache_alone() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/capsule");
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
record.append(&[message(&id_at(1), "user", "a")]);
let path = record.path();
let mut cache = RecordCache::default();
assert_eq!(
page(&mut cache, Some(&path), workdir.path(), None, 10)
.unwrap()
.total,
1
);
assert!(page(&mut cache, None, workdir.path(), None, 10)
.unwrap()
.messages
.is_empty());
assert_eq!(
page(&mut cache, Some(&path), workdir.path(), None, 10)
.unwrap()
.total,
1
);
assert_eq!(cache.reads(), 1, "the parse survived the empty read");
}
#[test]
fn a_malformed_line_is_skipped_and_reported() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let dir = home.path().join("conversations/capsule/ctx_1");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(RECORD_FILE_NAME);
let mut raw = String::new();
for n in 1..=3 {
raw.push_str(&serde_json::to_string(&message(&id_at(n), "user", "m")).unwrap());
raw.push('\n');
}
raw.push_str("{\"role\":\"user\"");
std::fs::write(&path, raw).unwrap();
let page = page(
&mut RecordCache::default(),
Some(&path),
workdir.path(),
None,
10,
)
.unwrap();
assert_eq!(page.total, 3);
assert_eq!(page.messages.len(), 3);
let log =
std::fs::read_to_string(workdir.path().join("logs/bootstrap.log")).unwrap_or_default();
assert_eq!(
log.lines()
.filter(|line| line.contains("line 4 is not a message"))
.count(),
1,
"reported once, naming the line: {log}"
);
}
#[test]
fn a_failed_write_is_retried_and_leaves_no_hole() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/capsule");
let blocker = root.join("ctx_1");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(&blocker, "").unwrap();
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
let first = message(&id_at(1), "user", "a");
let second = message(&id_at(2), "assistant", "b");
record.append(std::slice::from_ref(&first));
assert!(
blocker.is_file(),
"the directory chain could not be created"
);
std::fs::remove_file(&blocker).unwrap();
record.append(&[first, second]);
let written: Vec<Value> = std::fs::read_to_string(record.path())
.unwrap()
.lines()
.filter_map(parse_message_line)
.collect();
assert_eq!(
written
.iter()
.map(|m| crate::agent::message_id(m).unwrap())
.collect::<Vec<_>>(),
vec![id_at(1), id_at(2)],
"the message the failure cost is on disk, before the one that followed it"
);
}
#[test]
fn a_paging_loop_reads_the_record_once() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let root = home.path().join("conversations/capsule");
let mut record =
ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
record.append(
&(1..=250)
.map(|n| message(&id_at(n), "user", "m"))
.collect::<Vec<_>>(),
);
let path = record.path();
let mut cache = RecordCache::default();
let mut pages = 0;
let mut seen = Vec::new();
let mut cursor: Option<String> = None;
loop {
let page = page(
&mut cache,
Some(&path),
workdir.path(),
cursor.as_deref(),
25,
)
.unwrap();
pages += 1;
seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
match page.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
assert_eq!(pages, 10);
assert_eq!(seen, (1..=250).rev().map(id_at).collect::<Vec<_>>());
assert_eq!(cache.reads(), 1, "one read for the whole walk");
record.append(
&(251..=260)
.map(|n| message(&id_at(n), "user", "m"))
.collect::<Vec<_>>(),
);
let mut seen = Vec::new();
let mut cursor: Option<String> = None;
let mut first_total = None;
loop {
let page = page(
&mut cache,
Some(&path),
workdir.path(),
cursor.as_deref(),
25,
)
.unwrap();
first_total.get_or_insert(page.total);
seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
match page.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
assert_eq!(first_total, Some(260));
assert_eq!(seen, (1..=260).rev().map(id_at).collect::<Vec<_>>());
assert_eq!(cache.reads(), 2, "one further read for the second walk");
}
#[test]
fn a_malformed_line_is_reported_once_for_a_whole_walk() {
let home = tempfile::tempdir().unwrap();
let workdir = tempfile::tempdir().unwrap();
let dir = home.path().join("conversations/capsule/ctx_1");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(RECORD_FILE_NAME);
let mut raw = String::new();
for n in 1..=10 {
raw.push_str(&serde_json::to_string(&message(&id_at(n), "user", "m")).unwrap());
raw.push('\n');
}
raw.push_str("{\"role\":\"user\"");
std::fs::write(&path, raw).unwrap();
let mut cache = RecordCache::default();
let mut cursor: Option<String> = None;
let mut pages = 0;
loop {
let page = page(
&mut cache,
Some(&path),
workdir.path(),
cursor.as_deref(),
2,
)
.unwrap();
pages += 1;
assert_eq!(page.total, 10);
match page.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
assert_eq!(pages, 5);
let log =
std::fs::read_to_string(workdir.path().join("logs/bootstrap.log")).unwrap_or_default();
assert_eq!(
log.lines()
.filter(|line| line.contains("line 11 is not a message"))
.count(),
1,
"one entry for the whole walk: {log}"
);
}
#[test]
fn record_off_wins_over_a_declared_store() {
let off = murmur_artifact::ContextConfig {
max_tokens: None,
record: false,
record_store: Some("shey".to_string()),
seed_budget: murmur_artifact::DEFAULT_SEED_BUDGET,
seed_overflow_margin: murmur_artifact::DEFAULT_SEED_OVERFLOW_MARGIN,
retain: None,
};
assert_eq!(resolve_record_name(Some(&off), "capsule"), None);
let on = murmur_artifact::ContextConfig {
record: true,
..off.clone()
};
assert_eq!(
resolve_record_name(Some(&on), "capsule"),
Some("shey".to_string())
);
assert_eq!(
resolve_record_name(None, "capsule"),
Some("capsule".to_string()),
"a capsule declaring no context block still records"
);
}
#[test]
fn a_malformed_record_name_is_refused_by_name() {
for value in ["", "../escape", "/abs", "a/b", ".hidden"] {
let err = validate_record_segment("context.record_store", value)
.expect_err("'{value}' must be refused");
let message = err.to_string();
assert!(message.contains(&format!("'{value}'")), "{message}");
assert!(message.contains("single path segment"), "{message}");
assert!(message.contains("context.record_store"), "{message}");
}
validate_record_segment("--context", "ctx_fixed").unwrap();
}
}