use crate::agent::ToolCall;
use crate::compaction::ProjectionStep;
use crate::provider::{Message, Role};
use crate::todo::TodoState;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::io::Read;
use std::path::PathBuf;
const MAX_SESSION_FILE_BYTES: u64 = 10 * 1024 * 1024;
fn read_limited_file(path: &std::path::Path) -> std::io::Result<String> {
let file = std::fs::File::open(path)?;
let meta = file.metadata()?;
if !meta.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"path is not a regular file",
));
}
if meta.len() > MAX_SESSION_FILE_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"session file too large: {} bytes (max {MAX_SESSION_FILE_BYTES})",
meta.len()
),
));
}
let mut content = String::new();
file.take(MAX_SESSION_FILE_BYTES + 1)
.read_to_string(&mut content)?;
if content.len() as u64 > MAX_SESSION_FILE_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"file grew too large while reading",
));
}
Ok(content)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub id: u64,
pub parent_id: Option<u64>,
pub role: Role,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ToolCall>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionProjection {
pub summary: String,
pub archived_ids: Vec<u64>,
#[serde(default)]
pub step: ProjectionStep,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub name: String,
pub entries: Vec<Entry>,
#[serde(default)]
pub todos: TodoState,
#[serde(default)]
pub projections: Vec<SessionProjection>,
next_id: u64,
}
impl Session {
pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
entries: Vec::new(),
todos: TodoState::default(),
projections: Vec::new(),
next_id: 1,
}
}
pub fn append(&mut self, role: Role, content: impl Into<String>) -> u64 {
let id = self.next_id;
self.next_id += 1;
let parent = self.entries.last().map(|e| e.id);
self.entries.push(Entry {
id,
parent_id: parent,
role,
content: content.into(),
tool_call_id: None,
tool_calls: Vec::new(),
});
id
}
pub fn wipe_planning_tokens(&mut self) {
self.entries
.retain(|entry| !crate::agent::is_planning_content(&entry.content));
let ids: std::collections::HashSet<u64> =
self.entries.iter().map(|entry| entry.id).collect();
let mut prev: Option<u64> = None;
for entry in &mut self.entries {
if entry.parent_id.is_some_and(|parent| !ids.contains(&parent)) {
entry.parent_id = prev;
}
prev = Some(entry.id);
}
}
pub fn clear(&mut self) {
self.entries.clear();
self.projections.clear();
self.next_id = 1;
}
pub fn replace_messages(&mut self, messages: &[Message]) {
self.entries.clear();
self.projections.clear();
self.next_id = 1;
for message in messages {
self.append_message(message);
}
}
pub fn append_message(&mut self, message: &Message) -> u64 {
let id = self.next_id;
self.next_id += 1;
let parent = self.entries.last().map(|e| e.id);
self.entries.push(Entry {
id,
parent_id: parent,
role: message.role,
content: message.content.clone(),
tool_call_id: message.tool_call_id.clone(),
tool_calls: message.tool_calls.clone(),
});
id
}
pub fn fork(&self, from_entry: u64) -> Self {
let mut forked = Self::new(format!("{}-fork", self.id), format!("{} (fork)", self.name));
for entry in &self.entries {
forked.entries.push(entry.clone());
if entry.id == from_entry {
break;
}
}
forked.projections = self
.projections
.iter()
.map(|projection| SessionProjection {
summary: projection.summary.clone(),
archived_ids: projection
.archived_ids
.iter()
.copied()
.filter(|id| forked.entries.iter().any(|entry| entry.id == *id))
.collect(),
step: projection.step,
})
.filter(|projection| {
!projection.archived_ids.is_empty() || !projection.summary.is_empty()
})
.collect();
forked.next_id = self.next_id;
forked
}
pub fn record_projection(&mut self, projection: SessionProjection) {
if projection.archived_ids.is_empty() && projection.summary.is_empty() {
return;
}
self.projections.push(projection);
}
pub fn archived_ids(&self) -> HashSet<u64> {
self.projections
.iter()
.flat_map(|projection| projection.archived_ids.iter().copied())
.collect()
}
pub fn live_entries(&self) -> Vec<&Entry> {
let archived = self.archived_ids();
self.entries
.iter()
.filter(|entry| !archived.contains(&entry.id))
.collect()
}
pub fn live_messages(&self) -> Vec<Message> {
self.live_entries()
.into_iter()
.map(entry_to_message)
.collect()
}
pub fn provider_messages(&self) -> Vec<Message> {
let archived = self.archived_ids();
let mut out = Vec::new();
let mut inserted = false;
for entry in &self.entries {
if archived.contains(&entry.id) {
continue;
}
if !inserted && entry.role != Role::System {
push_projection_summaries(&self.projections, &mut out);
inserted = true;
}
out.push(entry_to_message(entry));
}
if !inserted {
push_projection_summaries(&self.projections, &mut out);
}
out
}
pub fn ids_matching(&self, dropped: &[Message]) -> Vec<u64> {
let mut next = dropped.iter();
let mut expected = next.next();
let mut ids = Vec::new();
for entry in self.live_entries() {
let Some(message) = expected else {
break;
};
if entry_matches(entry, message) {
ids.push(entry.id);
expected = next.next();
}
}
ids
}
pub fn merge(&mut self, other: &Self) -> usize {
let start = self.next_id;
for entry in &other.entries {
self.append(entry.role, entry.content.clone());
}
(self.next_id - start) as usize
}
pub fn save_jsonl(&self, dir: &std::path::Path) -> std::io::Result<PathBuf> {
crate::tools::common::validate_identifier(&self.id)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{}.jsonl", self.id));
let mut content = String::new();
let redactor = crate::secrets::Redactor::new();
for entry in &self.entries {
let mut safe_entry = entry.clone();
safe_entry.content = redactor.redact(&safe_entry.content);
content.push_str(&serde_json::to_string(&safe_entry).unwrap());
content.push('\n');
}
content.push_str(
&serde_json::json!({"type": "session_todos", "todos": self.todos}).to_string(),
);
content.push('\n');
for projection in &self.projections {
content.push_str(
&serde_json::json!({
"type": "projection",
"summary": projection.summary,
"archived_ids": projection.archived_ids,
"step": projection.step,
})
.to_string(),
);
content.push('\n');
}
std::fs::write(&path, content)?;
Ok(path)
}
pub fn load_jsonl(path: &std::path::Path) -> std::io::Result<Self> {
let content = read_limited_file(path)?;
let id = path.file_stem().unwrap().to_string_lossy().to_string();
let mut session = Self::new(id.clone(), id);
for line in content.lines() {
if line.is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if value.get("type").and_then(|value| value.as_str()) == Some("session_todos") {
if let Some(todos) = value.get("todos") {
if let Ok(todos) = serde_json::from_value(todos.clone()) {
session.todos = todos;
}
}
} else if value.get("type").and_then(|value| value.as_str()) == Some("projection") {
if let Ok(projection) = serde_json::from_value::<SessionProjection>(value) {
session.record_projection(projection);
}
} else if let Ok(entry) = serde_json::from_value::<Entry>(value) {
if entry.id >= session.next_id {
session.next_id = entry.id + 1;
}
session.entries.push(entry);
}
}
Ok(session)
}
pub fn export_codex_jsonl(&self, path: &std::path::Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out = String::new();
let redactor = crate::secrets::Redactor::new();
let meta = serde_json::json!({
"type": "session_meta",
"id": self.id,
"name": self.name,
"format": "rx4-codex-jsonl-v1",
});
out.push_str(&meta.to_string());
out.push('\n');
out.push_str(
&serde_json::json!({"type": "session_todos", "todos": self.todos}).to_string(),
);
out.push('\n');
for projection in &self.projections {
out.push_str(
&serde_json::json!({
"type": "projection",
"summary": projection.summary,
"archived_ids": projection.archived_ids,
"step": projection.step,
})
.to_string(),
);
out.push('\n');
}
for entry in &self.entries {
let safe_content = redactor.redact(&entry.content);
let mut line = serde_json::json!({
"type": "message",
"id": entry.id,
"parent_id": entry.parent_id,
"role": entry.role.to_string(),
"content": safe_content,
});
if let Some(tid) = &entry.tool_call_id {
line["tool_call_id"] = serde_json::json!(tid);
}
if !entry.tool_calls.is_empty() {
line["tool_calls"] = serde_json::to_value(&entry.tool_calls).unwrap_or_default();
}
out.push_str(&line.to_string());
out.push('\n');
}
std::fs::write(path, out)
}
pub fn import_codex_jsonl(path: &std::path::Path) -> std::io::Result<Self> {
let content = read_limited_file(path)?;
let fallback_id = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "imported".into());
let mut session = Self::new(fallback_id.clone(), fallback_id);
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
session.process_codex_line(&v);
}
Ok(session)
}
fn process_codex_line(&mut self, v: &serde_json::Value) {
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
if ty == "session_meta" {
self.process_session_meta(v);
} else if ty == "session_todos" {
self.process_session_todos(v);
} else if ty == "projection" {
if let Ok(projection) = serde_json::from_value::<SessionProjection>(v.clone()) {
self.record_projection(projection);
}
} else if ty == "message" || v.get("role").is_some() {
self.process_message(v);
}
}
fn process_session_meta(&mut self, v: &serde_json::Value) {
if let Some(s) = v.get("id").and_then(|x| x.as_str()) {
if let Err(e) = crate::tools::common::validate_identifier(s) {
tracing::warn!("rejecting malicious session id '{s}': {e}");
} else {
self.id = s.to_string();
}
}
if let Some(s) = v.get("name").and_then(|x| x.as_str()) {
self.name = s.to_string();
}
}
fn process_session_todos(&mut self, v: &serde_json::Value) {
if let Some(todos) = v.get("todos") {
if let Ok(todos) = serde_json::from_value(todos.clone()) {
self.todos = todos;
}
}
}
fn process_message(&mut self, v: &serde_json::Value) {
let role_str = v.get("role").and_then(|r| r.as_str()).unwrap_or("user");
let role = match role_str {
"assistant" => Role::Assistant,
"system" => Role::System,
"tool" => Role::Tool,
_ => Role::User,
};
let text = v
.get("content")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
let tool_call_id = v
.get("tool_call_id")
.and_then(|x| x.as_str())
.map(str::to_string);
let tool_calls = v
.get("tool_calls")
.and_then(|x| serde_json::from_value(x.clone()).ok())
.unwrap_or_default();
if let Some(eid) = v.get("id").and_then(|x| x.as_u64()) {
let parent = v.get("parent_id").and_then(|x| x.as_u64());
if eid >= self.next_id {
self.next_id = eid + 1;
}
self.entries.push(Entry {
id: eid,
parent_id: parent,
role,
content: text,
tool_call_id,
tool_calls,
});
} else {
self.append(role, text);
if let Some(entry) = self.entries.last_mut() {
entry.tool_call_id = tool_call_id;
entry.tool_calls = tool_calls;
}
}
}
pub fn messages(&self) -> Vec<Message> {
self.entries.iter().map(entry_to_message).collect()
}
pub fn serialize_provider_request(
&self,
system: &Option<String>,
tools: &[serde_json::Value],
) -> Vec<u8> {
serde_json::to_vec(&(system, tools, self.provider_messages())).unwrap_or_default()
}
pub fn replay_provider_request(
path: &std::path::Path,
system: &Option<String>,
tools: &[serde_json::Value],
) -> std::io::Result<Vec<u8>> {
let session = Self::load_jsonl(path)?;
Ok(session.serialize_provider_request(system, tools))
}
#[cfg(feature = "sqlite-sessions")]
pub fn save_sqlite(&self, path: &std::path::Path) -> Result<(), String> {
use rusqlite::{params, Connection};
let mut conn = Connection::open(path).map_err(|e| e.to_string())?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
next_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS entries (
session_id TEXT NOT NULL,
id INTEGER NOT NULL,
parent_id INTEGER,
role TEXT NOT NULL,
content TEXT NOT NULL,
tool_call_id TEXT,
tool_calls TEXT,
PRIMARY KEY (session_id, id)
);",
)
.map_err(|e| e.to_string())?;
let _ = conn.execute("ALTER TABLE entries ADD COLUMN tool_call_id TEXT", []);
let _ = conn.execute("ALTER TABLE entries ADD COLUMN tool_calls TEXT", []);
let tx = conn.transaction().map_err(|e| e.to_string())?;
tx.execute(
"INSERT OR REPLACE INTO sessions (id, name, next_id) VALUES (?1, ?2, ?3)",
params![self.id, self.name, self.next_id as i64],
)
.map_err(|e| e.to_string())?;
tx.execute(
"DELETE FROM entries WHERE session_id = ?1",
params![self.id],
)
.map_err(|e| e.to_string())?;
{
let mut stmt = tx
.prepare(
"INSERT INTO entries (session_id, id, parent_id, role, content, tool_call_id, tool_calls)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)
.map_err(|e| e.to_string())?;
let redactor = crate::secrets::Redactor::new();
for entry in &self.entries {
let safe_content = redactor.redact(&entry.content);
let tool_calls = if entry.tool_calls.is_empty() {
None
} else {
Some(serde_json::to_string(&entry.tool_calls).unwrap_or_default())
};
stmt.execute(params![
self.id,
entry.id as i64,
entry.parent_id.map(|p| p as i64),
entry.role.to_string(),
safe_content,
entry.tool_call_id.as_deref(),
tool_calls,
])
.map_err(|e| e.to_string())?;
}
}
tx.commit().map_err(|e| e.to_string())?;
let _ = conn.execute("ALTER TABLE sessions ADD COLUMN projections TEXT", []);
let encoded = serde_json::to_string(&self.projections).unwrap_or_else(|_| "[]".into());
let _ = conn.execute(
"UPDATE sessions SET projections = ?1 WHERE id = ?2",
params![encoded, self.id],
);
Ok(())
}
#[cfg(feature = "sqlite-sessions")]
pub fn load_sqlite(path: &std::path::Path) -> Result<Self, String> {
use rusqlite::{params, Connection};
let conn = Connection::open(path).map_err(|e| e.to_string())?;
let (id, name, next_id): (String, String, i64) = conn
.query_row(
"SELECT id, name, next_id FROM sessions LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.map_err(|e| e.to_string())?;
let mut session = Self::new(id.clone(), name);
session.next_id = next_id as u64;
let _ = conn.execute("ALTER TABLE entries ADD COLUMN tool_call_id TEXT", []);
let _ = conn.execute("ALTER TABLE entries ADD COLUMN tool_calls TEXT", []);
let mut stmt = conn
.prepare(
"SELECT id, parent_id, role, content, tool_call_id, tool_calls FROM entries
WHERE session_id = ?1 ORDER BY id ASC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![id], |row| {
let role_s: String = row.get(2)?;
let role = match role_s.as_str() {
"system" => Role::System,
"user" => Role::User,
"assistant" => Role::Assistant,
"tool" => Role::Tool,
other => {
return Err(rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Text,
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unknown role: {other}"),
)),
));
}
};
let tool_calls = row
.get::<_, Option<String>>(5)?
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
Ok(Entry {
id: row.get::<_, i64>(0)? as u64,
parent_id: row.get::<_, Option<i64>>(1)?.map(|p| p as u64),
role,
content: row.get(3)?,
tool_call_id: row.get(4)?,
tool_calls,
})
})
.map_err(|e| e.to_string())?;
for row in rows {
session.entries.push(row.map_err(|e| e.to_string())?);
}
let _ = conn.execute("ALTER TABLE sessions ADD COLUMN projections TEXT", []);
if let Ok(Some(raw)) = conn.query_row(
"SELECT projections FROM sessions WHERE id = ?1",
params![id],
|row| row.get::<_, Option<String>>(0),
) {
if let Ok(projections) = serde_json::from_str::<Vec<SessionProjection>>(&raw) {
session.projections = projections;
}
}
Ok(session)
}
}
fn entry_to_message(entry: &Entry) -> Message {
Message {
role: entry.role,
content: entry.content.clone(),
tool_call_id: entry.tool_call_id.clone(),
tool_calls: entry.tool_calls.clone(),
}
}
fn entry_matches(entry: &Entry, message: &Message) -> bool {
entry.role == message.role
&& entry.content == message.content
&& entry.tool_call_id == message.tool_call_id
&& entry.tool_calls == message.tool_calls
}
fn push_projection_summaries(projections: &[SessionProjection], out: &mut Vec<Message>) {
for projection in projections {
if projection.summary.is_empty() {
continue;
}
out.push(Message::system(projection.summary.clone()));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jsonl_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut s = Session::new("test_jsonl_session", "jsonl-test");
s.append(Role::User, "hello");
let secret = format!("sk-{}", "a".repeat(48));
s.append(Role::Assistant, format!("data: {}", secret));
let path = s.save_jsonl(dir.path()).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(!on_disk.contains(&secret));
assert!(on_disk.contains("[REDACTED:api-key]"));
let loaded = Session::load_jsonl(&path).unwrap();
assert_eq!(loaded.id, "test_jsonl_session");
assert_eq!(loaded.entries.len(), 2);
assert_eq!(loaded.entries[0].content, "hello");
assert_eq!(loaded.entries[1].content, "data: [REDACTED:api-key]");
assert!(loaded.todos.items.is_empty());
}
#[test]
fn save_jsonl_invalid_id() {
let dir = tempfile::tempdir().unwrap();
let s = Session::new("../invalid", "test");
let err = s.save_jsonl(dir.path()).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn load_jsonl_recovers_gracefully() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("graceful.jsonl");
let content = "\
{\"id\":1,\"parent_id\":null,\"role\":\"user\",\"content\":\"valid first\"}
not valid json at all
{\"type\":\"session_todos\",\"todos\":{\"items\":[{\"id\":\"t1\",\"content\":\"a todo\",\"status\":\"pending\",\"creation_confidence\":100,\"verification_attempts\":0}]}}
{\"id\":2,\"parent_id\":1,\"role\":\"assistant\"} // missing content, malformed
{\"id\":3,\"parent_id\":1,\"role\":\"assistant\",\"content\":\"valid second\"}
";
std::fs::write(&path, content).unwrap();
let loaded = Session::load_jsonl(&path).unwrap();
assert_eq!(loaded.id, "graceful");
assert_eq!(loaded.name, "graceful");
assert_eq!(loaded.entries.len(), 2);
assert_eq!(loaded.entries[0].id, 1);
assert_eq!(loaded.entries[0].content, "valid first");
assert_eq!(loaded.entries[1].id, 3);
assert_eq!(loaded.entries[1].content, "valid second");
assert_eq!(loaded.todos.items.len(), 1);
assert_eq!(loaded.todos.items[0].content, "a todo");
assert_eq!(loaded.todos.items[0].id, "t1");
assert_eq!(loaded.next_id, 4);
}
#[test]
fn append_and_fork() {
let mut s = Session::new("s1", "test");
let id1 = s.append(Role::System, "sys");
let id2 = s.append(Role::User, "hello");
let id3 = s.append(Role::Assistant, "hi");
let forked1 = s.fork(id2);
assert_eq!(forked1.id, "s1-fork");
assert_eq!(forked1.name, "test (fork)");
assert_eq!(forked1.next_id, s.next_id);
assert_eq!(forked1.entries.len(), 2);
assert_eq!(forked1.entries[0].id, id1);
assert_eq!(forked1.entries[0].content, "sys");
assert_eq!(forked1.entries[1].id, id2);
assert_eq!(forked1.entries[1].content, "hello");
let forked2 = s.fork(999);
assert_eq!(forked2.id, "s1-fork");
assert_eq!(forked2.name, "test (fork)");
assert_eq!(forked2.next_id, s.next_id);
assert_eq!(forked2.entries.len(), 3);
assert_eq!(forked2.entries[2].id, id3);
assert_eq!(forked2.entries[2].content, "hi");
}
#[test]
fn load_jsonl_recovers_from_malformed_lines() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("malformed.jsonl");
let valid_entry = serde_json::json!({
"id": 1,
"parent_id": null,
"role": "user",
"content": "valid message"
});
let content = format!(
"\n\n{}\nmalformed json\n{}\n{}\n",
"{}", valid_entry,
serde_json::json!({"type": "session_todos", "todos": {"items": []}})
);
std::fs::write(&path, content).unwrap();
let loaded = Session::load_jsonl(&path).unwrap();
assert_eq!(loaded.id, "malformed");
assert_eq!(loaded.entries.len(), 1);
assert_eq!(loaded.entries[0].content, "valid message");
assert!(loaded.todos.items.is_empty());
}
#[test]
fn codex_jsonl_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("codex.jsonl");
let mut s = Session::new("codex1", "export-test");
s.append(Role::User, "ping");
s.append(Role::Assistant, "pong");
s.export_codex_jsonl(&path).unwrap();
let loaded = Session::import_codex_jsonl(&path).unwrap();
assert_eq!(loaded.id, "codex1");
assert_eq!(loaded.name, "export-test");
assert_eq!(loaded.entries.len(), 2);
assert_eq!(loaded.entries[0].content, "ping");
assert!(loaded.todos.items.is_empty());
assert_eq!(loaded.entries[1].content, "pong");
}
#[test]
fn persistence_redacts_secrets_without_mutating_session() {
let dir = tempfile::tempdir().unwrap();
let secret = format!("sk-{}", "a".repeat(48));
let mut s = Session::new("safe", "redaction-test");
s.append(Role::Assistant, format!("token {secret}"));
let path = s.save_jsonl(dir.path()).unwrap();
let on_disk = std::fs::read_to_string(path).unwrap();
assert!(!on_disk.contains(&secret));
assert!(on_disk.contains("[REDACTED:api-key]"));
assert!(s.entries[0].content.contains(&secret));
}
#[test]
fn merge_sessions() {
let mut s1 = Session::new("s1", "base");
s1.append(Role::User, "q1");
s1.append(Role::Assistant, "a1");
let mut s2 = Session::new("s2", "branch");
s2.append(Role::User, "q2");
s2.append(Role::Assistant, "a2");
s2.append(Role::User, "q3");
let merged_count = s1.merge(&s2);
assert_eq!(merged_count, 3);
assert_eq!(s1.entries.len(), 5);
assert_eq!(s1.entries[0].content, "q1");
assert_eq!(s1.entries[1].content, "a1");
assert_eq!(s1.entries[2].content, "q2");
assert_eq!(s1.entries[3].content, "a2");
assert_eq!(s1.entries[4].content, "q3");
assert_eq!(s1.entries[2].id, 3);
assert_eq!(s1.entries[3].id, 4);
assert_eq!(s1.entries[4].id, 5);
assert_eq!(s1.next_id, 6);
}
#[test]
fn save_jsonl_validates_id() {
let dir = tempfile::tempdir().unwrap();
let s = Session::new("../invalid", "test");
let err = s.save_jsonl(dir.path()).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn save_jsonl_writes_entries_and_todos() {
let dir = tempfile::tempdir().unwrap();
let mut s = Session::new("test-session", "test");
let secret = format!("sk-{}", "a".repeat(48));
s.append(Role::User, format!("hello {}", secret));
s.todos.items.push(crate::todo::TodoItem {
id: "t1".to_string(),
content: "a task".to_string(),
status: crate::todo::TodoStatus::Pending,
creation_confidence: 90,
completion_confidence: None,
verification_attempts: 0,
});
let path = s.save_jsonl(dir.path()).unwrap();
assert_eq!(path, dir.path().join("test-session.jsonl"));
let content = std::fs::read_to_string(path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
let entry: Entry = serde_json::from_str(lines[0]).unwrap();
assert_eq!(entry.role, Role::User);
assert_eq!(entry.content, "hello [REDACTED:api-key]");
assert!(s.entries[0].content.contains(&secret));
let todos: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(
todos.get("type").unwrap().as_str().unwrap(),
"session_todos"
);
let items = todos
.get("todos")
.unwrap()
.get("items")
.unwrap()
.as_array()
.unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].get("content").unwrap().as_str().unwrap(), "a task");
}
#[cfg(feature = "sqlite-sessions")]
#[test]
fn sqlite_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.db");
let mut s = Session::new("s1", "test");
s.append(Role::User, "hello");
s.append_message(&Message {
role: Role::Assistant,
content: "hi".into(),
tool_call_id: None,
tool_calls: vec![crate::agent::ToolCall {
id: "c1".into(),
name: "read".into(),
arguments: "{}".into(),
}],
});
s.append_message(&Message::tool("c1", "ok"));
s.save_sqlite(&path).unwrap();
let loaded = Session::load_sqlite(&path).unwrap();
assert_eq!(loaded.id, "s1");
assert_eq!(loaded.name, "test");
assert_eq!(loaded.entries.len(), 3);
assert_eq!(loaded.entries[0].content, "hello");
assert_eq!(loaded.entries[1].role, Role::Assistant);
assert_eq!(loaded.entries[1].tool_calls.len(), 1);
assert_eq!(loaded.entries[1].tool_calls[0].id, "c1");
assert_eq!(loaded.entries[2].tool_call_id.as_deref(), Some("c1"));
assert_eq!(loaded.next_id, s.next_id);
}
#[test]
fn replace_messages_rebuilds_entries() {
let mut session = Session::new("rep", "rep");
session.append(Role::User, "old");
session.replace_messages(&[Message::system("sys"), Message::user("kept")]);
let texts: Vec<_> = session.messages().into_iter().map(|m| m.content).collect();
assert_eq!(texts, vec!["sys".to_string(), "kept".to_string()]);
assert_eq!(session.entries[0].id, 1);
assert_eq!(session.next_id, 3);
}
#[test]
fn wipe_planning_tokens_drops_planning_entries() {
let mut session = Session::new("wipe", "wipe");
session.append(Role::System, "sys");
session.append(Role::Assistant, "<planning>think</planning>");
session.append(Role::User, "go");
session.append(Role::Assistant, "PLAN: do it");
session.wipe_planning_tokens();
let texts: Vec<_> = session.messages().into_iter().map(|m| m.content).collect();
assert_eq!(texts, vec!["sys".to_string(), "go".to_string()]);
let ids: std::collections::HashSet<u64> = session.entries.iter().map(|e| e.id).collect();
for entry in &session.entries {
if let Some(parent) = entry.parent_id {
assert!(
ids.contains(&parent),
"orphan parent_id {parent} on entry {}",
entry.id
);
}
}
assert_eq!(session.entries[0].parent_id, None);
assert_eq!(session.entries[1].parent_id, Some(session.entries[0].id));
}
#[test]
fn clear_drops_entries() {
let mut session = Session::new("clr", "clr");
session.append(Role::User, "old");
session.append(Role::Assistant, "reply");
session.clear();
assert!(session.entries.is_empty());
assert!(session.messages().is_empty());
let id = session.append(Role::User, "fresh");
assert_eq!(id, 1);
assert_eq!(session.entries[0].parent_id, None);
}
#[test]
fn request_rebuilds_from_session_log_not_live_vec() {
let dir = tempfile::tempdir().unwrap();
let mut session = Session::new("log", "invariant");
session.append(Role::System, "sys");
session.append(Role::User, "hello");
session.append(Role::Assistant, "hi");
let path = session.save_jsonl(dir.path()).unwrap();
let system = Some("sys".to_string());
let tools = vec![serde_json::json!({"name": "read"})];
let from_live_before = session.serialize_provider_request(&system, &tools);
let mut live = session.messages();
live[1].content = "MUTATED IN MEMORY".to_string();
let from_replay = Session::replay_provider_request(&path, &system, &tools).unwrap();
assert_eq!(from_live_before, from_replay);
assert_ne!(
serde_json::to_vec(&(&system, &tools, &live)).unwrap(),
from_replay
);
}
#[test]
fn projection_ledger_survives_jsonl_and_shapes_provider_request() {
let dir = tempfile::tempdir().unwrap();
let mut session = Session::new("proj", "proj");
session.append(Role::System, "sys");
let old = session.append(Role::User, "old turn");
session.append(Role::User, "recent tail");
session.record_projection(SessionProjection {
summary: "[context compacted] checkpoint retained".into(),
archived_ids: vec![old],
step: ProjectionStep::Fold,
});
let reconstructed = session.provider_messages();
assert!(reconstructed
.iter()
.any(|m| m.content.contains("checkpoint retained")));
assert!(reconstructed
.iter()
.any(|m| m.content.contains("recent tail")));
assert!(reconstructed.iter().all(|m| m.content != "old turn"));
assert!(
session.messages().iter().any(|m| m.content == "old turn"),
"append-only log must keep archived turns"
);
let path = session.save_jsonl(dir.path()).unwrap();
let loaded = Session::load_jsonl(&path).unwrap();
assert_eq!(loaded.projections.len(), 1);
let replayed = Session::replay_provider_request(&path, &None, &[]).unwrap();
let expected = session.serialize_provider_request(&None, &[]);
assert_eq!(replayed, expected);
let replayed_msgs = loaded.provider_messages();
assert!(replayed_msgs
.iter()
.any(|m| m.content.contains("checkpoint retained")));
}
}