use crate::{
core::{
provider::{AgentMessage, Usage},
session_v4::{
self, ContextEditReplacement, EffectiveLaneConfiguration, Entry, EntryBase,
ForkPosition, ForkScope, JsonlV4Header, LaneRecord, LaneReductionInput,
LaneReductionResult, RecordBase, RecordLogError, SessionMutation, SessionState,
entry_base, reduce_lane_state, set_record_base,
},
settings_manager::agent_dir,
},
error::{Error, Result},
utils::{
paths::{normalize_dir, read_first_line, strip_verbatim_prefix},
time::{chrono_ms_to_iso, now_ms},
},
};
use serde_json::{Value, json};
use std::{
collections::{HashMap, HashSet},
io::{BufRead, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
const MAIN_LANE: &str = "main";
pub const SESSION_VERSION: u32 = 4;
const SALVAGE_CAP_MIN: usize = 8;
const SALVAGE_CAP_MAX: usize = 32;
#[derive(Debug, Clone)]
pub struct SessionRepairPlan {
pub error: String,
pub total_lines: usize,
pub valid_lines: usize,
pub truncate_content: String,
pub salvage_content: Option<String>,
pub salvaged_skipped: usize,
}
impl SessionRepairPlan {
pub fn salvage_available(&self) -> bool {
self.salvage_content.is_some()
}
pub fn dropped_lines(&self) -> usize {
self.total_lines.saturating_sub(self.valid_lines)
}
}
pub enum OpenSessionResult {
Ok(Box<Session>),
Invalid(Error),
Repairable {
path: PathBuf,
plan: SessionRepairPlan,
},
}
impl From<OpenSessionResult> for Result<Session> {
fn from(ret: OpenSessionResult) -> Result<Session> {
match ret {
OpenSessionResult::Ok(s) => Ok(*s),
OpenSessionResult::Invalid(e) => Err(e),
OpenSessionResult::Repairable { plan, .. } => Err(Error::msg(plan.error)),
}
}
}
pub enum ContinueRecentResult {
Ok(Session),
Created(Session),
Repairable {
path: PathBuf,
plan: SessionRepairPlan,
},
Failed,
}
impl From<ContinueRecentResult> for Option<Session> {
fn from(ret: ContinueRecentResult) -> Option<Session> {
match ret {
ContinueRecentResult::Ok(s) | ContinueRecentResult::Created(s) => Some(s),
ContinueRecentResult::Repairable { .. } | ContinueRecentResult::Failed => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Session {
pub session_id: String,
pub cwd: String,
pub session_dir: PathBuf,
pub session_file: Option<PathBuf>,
pub persist: bool,
pub state: SessionState,
pub name: Option<String>,
persist_error: Option<String>,
header: JsonlV4Header,
}
fn salvage_cap_for(total_lines: usize) -> usize {
let mutations = total_lines.saturating_sub(1);
(mutations.saturating_mul(5) / 100).clamp(SALVAGE_CAP_MIN, SALVAGE_CAP_MAX)
}
fn write_repaired_file(file: &Path, content: &str) -> std::io::Result<()> {
if file.exists() {
let bak = file.with_extension("jsonl.bak");
std::fs::copy(file, &bak).map_err(|source| {
std::io::Error::new(
source.kind(),
format!(
"failed to back up {} to {} before repair: {}",
file.display(),
bak.display(),
source
),
)
})?;
}
let tmp = file.with_extension("jsonl.tmp");
std::fs::write(&tmp, content)?;
std::fs::rename(&tmp, file)
}
fn is_d_marker_error(e: &RecordLogError) -> bool {
matches!(
e,
RecordLogError::MissingLane { .. }
| RecordLogError::MissingLaneTarget { .. }
| RecordLogError::MissingParent { .. }
| RecordLogError::DoesNotChainToLaneLeaf { .. }
| RecordLogError::MissingLabelTarget { .. }
)
}
fn produced_line_ids(line: &str) -> Vec<String> {
serde_json::from_str::<Value>(line)
.ok()
.and_then(|v| {
v.get("id")
.and_then(|x| x.as_str())
.map(|s| vec![s.to_string()])
})
.unwrap_or_default()
}
fn referenced_line_ids(v: &Value) -> Vec<String> {
const REF_KEYS: &[&str] = &[
"parentId",
"leafId",
"runId",
"entryId",
"assistantEntryId",
"resultEntryId",
"targetId",
"sourceLeafId",
];
let mut out = Vec::new();
for key in REF_KEYS {
if let Some(s) = v.get(key).and_then(|x| x.as_str()) {
out.push(s.to_string());
}
}
if let Some(s) = v.pointer("/target/id").and_then(|x| x.as_str()) {
out.push(s.to_string());
}
out
}
fn suffix_has_dangling_reference(
suffix: &[&str],
dropped_ids: &[String],
state: &SessionState,
) -> bool {
if dropped_ids.is_empty() {
return false;
}
for line in suffix {
let Ok(v) = serde_json::from_str::<Value>(line.trim()) else {
continue; };
for r in referenced_line_ids(&v) {
if dropped_ids.iter().any(|d| d == &r) && !state.has_id(&r) {
return true;
}
}
}
false
}
struct MutationScan {
kept: Vec<SessionMutation>,
skipped: usize,
seq_fixed: usize,
prefix_len: usize,
first_failure: Option<(usize, String)>,
torn_tail: Option<(usize, String)>,
state: SessionState,
total_lines: usize,
}
fn read_session_file(path: &Path) -> Result<(String, JsonlV4Header)> {
let content = std::fs::read_to_string(path).map_err(|source| Error::Io {
context: format!("Failed to read session file: {}", path.display()),
source,
})?;
let header_line = content.lines().next().ok_or_else(|| {
Error::msg(format!(
"session file is missing a header: {}",
path.display()
))
})?;
let header_value: Value = serde_json::from_str(header_line).map_err(|source| Error::Json {
context: format!("Failed to parse session header: {}", path.display()),
source,
})?;
let kind = header_value
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("");
let version = header_value
.get("version")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if kind != "header" || version != u64::from(SESSION_VERSION) {
return Err(Error::msg(format!(
"session file is not a v4 record-log (kind=header, version={}): {}",
SESSION_VERSION,
path.display()
)));
}
let header: JsonlV4Header =
serde_json::from_value(header_value).map_err(|source| Error::Json {
context: format!("Failed to parse session header: {}", path.display()),
source,
})?;
Ok((content, header))
}
fn scan_mutations(lines: &[&str], path: &Path) -> MutationScan {
let mut state = SessionState::new();
let total_lines = lines.len();
let cap = salvage_cap_for(total_lines);
let mut kept: Vec<SessionMutation> = Vec::new(); let mut skipped = 0usize; let mut seq_fixed = 0usize; let mut skipped_seq = 0usize; let mut prefix_len = 0usize; let mut first_failure: Option<(usize, String)> = None; let mut torn_tail: Option<(usize, String)> = None; let mut physical = 1usize;
for idx in 1..lines.len() {
let trimmed = lines[idx].trim();
if trimmed.is_empty() {
physical += 1;
continue;
}
let is_last = idx + 1 == lines.len();
let mut m = match session_v4::parse_mutation(trimmed) {
Ok(m) => m,
Err(e) => {
if is_last && matches!(e, RecordLogError::InvalidJson { .. }) {
torn_tail = Some((
physical,
format!(
"Invalid session mutation at line {} ({}): {}",
physical + 1,
path.display(),
e
),
));
break;
}
if first_failure.is_none() {
prefix_len = kept.len();
first_failure = Some((
physical,
format!(
"Invalid session mutation at line {} ({}): {}",
physical + 1,
path.display(),
e
),
));
}
if skipped >= cap {
break;
}
if suffix_has_dangling_reference(
&lines[idx + 1..],
&produced_line_ids(trimmed),
&state,
) {
break;
}
skipped += 1;
physical += 1;
continue;
}
};
if skipped_seq > 0
&& session_v4::shift_mutation_seq(&mut m, -(skipped_seq as i64)).is_none()
{
if first_failure.is_none() {
prefix_len = kept.len();
first_failure = Some((
physical,
format!(
"Invalid session mutation at line {} ({}): has non-consecutive seq",
physical + 1,
path.display()
),
));
}
break;
}
match state.apply_mutation(m.clone()) {
Ok(()) => {
kept.push(m);
}
Err(mut e) => {
if matches!(e, RecordLogError::NonConsecutiveSeq { .. }) {
session_v4::set_mutation_seq(&mut m, state.next_sequence());
match state.apply_mutation(m.clone()) {
Ok(()) => {
seq_fixed += 1;
kept.push(m);
physical += 1;
continue;
}
Err(e2) => {
e = e2;
}
}
}
let msg = e.to_string();
if first_failure.is_none() {
prefix_len = kept.len();
first_failure = Some((
physical,
format!(
"Invalid session mutation at line {} ({}): {}",
physical + 1,
path.display(),
msg
),
));
}
if is_d_marker_error(&e) {
break; }
if skipped >= cap {
break; }
if suffix_has_dangling_reference(
&lines[idx + 1..],
&produced_line_ids(trimmed),
&state,
) {
break;
}
skipped += 1;
skipped_seq += 1; }
}
physical += 1;
}
MutationScan {
kept,
skipped,
seq_fixed,
prefix_len,
first_failure,
torn_tail,
state,
total_lines,
}
}
fn encode_prefix(header_line: &str, kept: &[SessionMutation], len: usize) -> String {
let mut out = String::from(header_line);
out.push('\n');
for m in &kept[..len] {
out.push_str(&session_v4::encode_mutation(m));
}
out
}
fn finalize_open(
file: PathBuf,
header: JsonlV4Header,
header_line: &str,
content: &str,
scan: MutationScan,
) -> OpenSessionResult {
let MutationScan {
kept,
skipped,
seq_fixed,
prefix_len,
first_failure,
torn_tail,
state,
total_lines,
} = scan;
if (torn_tail.is_some() && skipped == 0)
|| (torn_tail.is_none() && first_failure.is_none() && seq_fixed > 0)
{
_ = write_repaired_file(&file, &encode_prefix(header_line, &kept, kept.len()));
} else if torn_tail.is_none() && first_failure.is_none() && !content.ends_with('\n') {
if let Ok(mut f) = std::fs::OpenOptions::new().append(true).open(&file) {
_ = f.write_all(b"\n");
}
}
let plan_error = if torn_tail.is_some() && skipped == 0 {
None } else {
first_failure
.as_ref()
.map(|(_, e)| e.clone())
.or_else(|| torn_tail.as_ref().map(|(_, e)| e.clone()))
};
if let Some(err) = plan_error {
let truncate_content = encode_prefix(header_line, &kept, prefix_len);
let salvage_content = if skipped > 0 {
Some(encode_prefix(header_line, &kept, kept.len()))
} else {
None
};
return OpenSessionResult::Repairable {
path: file,
plan: SessionRepairPlan {
error: err,
total_lines,
valid_lines: truncate_content.lines().count(),
truncate_content,
salvage_content,
salvaged_skipped: skipped,
},
};
}
OpenSessionResult::Ok(Box::new(Session {
session_id: header.id.clone(),
cwd: header.cwd.clone(),
session_dir: file.parent().map(|p| p.to_path_buf()).unwrap_or_default(),
session_file: Some(file),
persist: true,
name: state.get_name().map(|s| s.to_string()),
state,
persist_error: None,
header,
}))
}
#[derive(Debug, Clone, Default)]
pub struct SessionCreateOptions {
pub cwd: String,
pub session_dir: Option<PathBuf>,
pub persist: bool,
pub session_id: Option<String>,
pub parent_session_id: Option<String>,
}
impl Session {
pub fn create(cwd: &str, session_dir: Option<PathBuf>, persist: bool) -> Result<Session> {
Self::create_with(SessionCreateOptions {
cwd: cwd.to_string(),
session_dir,
persist,
..Default::default()
})
}
pub fn create_with_id(
cwd: &str,
session_dir: Option<PathBuf>,
persist: bool,
session_id: &str,
) -> Result<Session> {
Self::create_with(SessionCreateOptions {
cwd: cwd.to_string(),
session_dir,
persist,
session_id: Some(session_id.to_string()),
..Default::default()
})
}
pub fn create_with(opts: SessionCreateOptions) -> Result<Session> {
let cwd = opts.cwd.as_str();
let session_id = match &opts.session_id {
Some(id) => {
if !is_valid_session_id(id) {
return Err(Error::Message(format!(
"Invalid session id: {}. Session id must be a valid v7 UUID (e.g. 0193f1b2-...-...-...-...)",
id
)));
}
id.clone()
}
None => new_session_id(),
};
let persist = opts.persist;
let dir = opts
.session_dir
.unwrap_or_else(|| default_session_dir(cwd, &agent_dir()));
let created_at = now_ms() as i64;
let header = JsonlV4Header::new(
session_id.clone(),
cwd.to_string(),
created_at,
opts.parent_session_id.clone(),
);
let mut session = Session {
session_id: session_id.clone(),
cwd: cwd.to_string(),
session_dir: dir,
session_file: None,
persist,
state: SessionState::new(),
name: None,
persist_error: None,
header: header.clone(),
};
if persist {
std::fs::create_dir_all(&session.session_dir).map_err(|source| Error::Io {
context: format!(
"Failed to create session directory: {}",
session.session_dir.display()
),
source,
})?;
let file = session
.session_dir
.join(session_file_name(created_at, &session_id));
std::fs::write(&file, header.encode()).map_err(|source| Error::Io {
context: format!("Failed to write session file: {}", file.display()),
source,
})?;
session.session_file = Some(file);
}
Ok(session)
}
pub fn parent_session_id(&self) -> Option<&str> {
self.header.parent_session_id.as_deref()
}
pub fn is_persisted(&self) -> bool {
self.persist
}
pub fn has_durable_file(&self) -> bool {
self.session_file.is_some()
}
pub fn get_session_file(&self) -> Option<&Path> {
self.session_file.as_deref()
}
pub fn open(path: &str) -> Result<Session> {
Self::open_checked(path).into()
}
pub fn open_checked(path: &str) -> OpenSessionResult {
let file = PathBuf::from(path);
let (content, header) = match read_session_file(&file) {
Ok(parsed) => parsed,
Err(e) => return OpenSessionResult::Invalid(e),
};
let lines: Vec<&str> = content.lines().collect();
let header_line = lines[0];
let scan = scan_mutations(&lines, &file);
finalize_open(file, header, header_line, &content, scan)
}
pub fn repair_write(path: &str, plan: &SessionRepairPlan, use_salvage: bool) -> Result<()> {
let content = if use_salvage {
plan.salvage_content.clone().ok_or_else(|| {
Error::msg("session salvage unavailable (broken suffix cannot be recovered)")
})?
} else {
plan.truncate_content.clone()
};
let file = PathBuf::from(path);
write_repaired_file(&file, &content).map_err(|source| Error::Io {
context: format!("Failed to write repaired session file: {}", file.display()),
source,
})
}
pub fn continue_recent(
cwd: &str,
session_dir: Option<PathBuf>,
persist: bool,
) -> Result<Session> {
match Self::continue_recent_checked(cwd, session_dir, persist) {
ContinueRecentResult::Ok(s) | ContinueRecentResult::Created(s) => Ok(s),
ContinueRecentResult::Repairable { plan, .. } => Err(Error::msg(plan.error)),
ContinueRecentResult::Failed => Err(Error::msg("Failed to continue recent session")),
}
}
pub fn continue_recent_checked(
cwd: &str,
session_dir: Option<PathBuf>,
persist: bool,
) -> ContinueRecentResult {
let default_dir = default_session_dir(cwd, &agent_dir());
let filter_cwd = session_dir
.as_ref()
.is_some_and(|d| normalize_dir(d) != normalize_dir(&default_dir));
let dir = session_dir.unwrap_or(default_dir);
for path in list_sessions(&dir) {
if filter_cwd && read_header_cwd(&path).as_deref() != Some(cwd) {
continue;
}
match Session::open_checked(&path.to_string_lossy()) {
OpenSessionResult::Ok(s) => {
if s.has_user_messages() {
return ContinueRecentResult::Ok(*s);
}
_ = std::fs::remove_file(&path);
}
OpenSessionResult::Invalid(_) => return ContinueRecentResult::Failed,
OpenSessionResult::Repairable { path, plan } => {
return ContinueRecentResult::Repairable { path, plan };
}
}
}
match Session::create(cwd, Some(dir), persist) {
Ok(s) => ContinueRecentResult::Created(s),
Err(_) => ContinueRecentResult::Failed,
}
}
pub fn has_user_messages(&self) -> bool {
self.state.entries().iter().any(|e| {
matches!(
e,
session_v4::Entry::Message { message, .. } if message.role == "user"
)
})
}
pub fn fork_from(
source_path: &str,
cwd: &str,
session_dir: Option<PathBuf>,
) -> Result<Session> {
let source = Session::open(source_path)?;
Self::fork_impl(&source, cwd, session_dir, ForkScope::Tree)
}
pub fn fork_at(
source_path: &str,
leaf_id: &str,
cwd: &str,
session_dir: Option<PathBuf>,
) -> Result<Session> {
let source = Session::open(source_path)?;
Self::fork_impl(
&source,
cwd,
session_dir,
ForkScope::Branch {
entry_id: Some(leaf_id.to_string()),
position: Some(ForkPosition::At),
},
)
}
fn fork_impl(
source: &Session,
cwd: &str,
session_dir: Option<PathBuf>,
scope: ForkScope,
) -> Result<Session> {
let dir = session_dir.unwrap_or_else(|| default_session_dir(cwd, &agent_dir()));
std::fs::create_dir_all(&dir).map_err(|source| Error::Io {
context: format!("Failed to create session directory: {}", dir.display()),
source,
})?;
let session_id = new_session_id();
let created_at = now_ms() as i64;
let header = JsonlV4Header::new(
session_id.clone(),
cwd.to_string(),
created_at,
Some(source.session_id.clone()),
);
let mutations = source.state.create_fork_mutations(&scope)?;
let mut content = header.encode();
for m in &mutations {
content.push_str(&session_v4::encode_mutation(m));
}
let file = dir.join(session_file_name(created_at, &session_id));
std::fs::write(&file, content).map_err(|source| Error::Io {
context: format!("Failed to write session file: {}", file.display()),
source,
})?;
let mut state = SessionState::new();
for m in mutations {
state.apply_mutation(m)?;
}
Ok(Session {
session_id,
cwd: cwd.to_string(),
session_dir: dir,
session_file: Some(file),
persist: true,
name: state.get_name().map(|s| s.to_string()),
state,
persist_error: None,
header,
})
}
fn commit_entry_lane(&mut self, entry: Entry) -> String {
let id = entry.id().to_string();
let seq = self.state.next_sequence();
let timestamp = now_ms() as i64;
let mut entry = entry;
{
let b = match &mut entry {
Entry::Message { base, .. }
| Entry::ModelChange { base, .. }
| Entry::ThinkingLevelChange { base, .. }
| Entry::ActiveToolsChange { base, .. }
| Entry::Compaction { base, .. }
| Entry::BranchSummary { base, .. }
| Entry::ContextEdit { base, .. }
| Entry::Custom { base, .. } => base,
};
b.seq = seq;
b.timestamp = timestamp;
b.parent_id = self.state.require_lane(MAIN_LANE).ok().flatten();
}
let mutation = SessionMutation::Entry {
lane: Some(MAIN_LANE.to_string()),
entry,
};
self.persist_mutation(&mutation);
_ = self.state.apply_mutation(mutation);
self.name = self.state.get_name().map(|s| s.to_string());
id
}
fn commit_record(&mut self, mut record: LaneRecord) -> String {
let id = record.id().to_string();
let seq = self.state.next_sequence();
let timestamp = now_ms() as i64;
set_record_base(
&mut record,
RecordBase {
id: id.clone(),
seq,
lane: MAIN_LANE.to_string(),
timestamp,
},
);
let mutation = SessionMutation::Record { record };
self.persist_mutation(&mutation);
_ = self.state.apply_mutation(mutation);
id
}
fn persist_mutation(&mut self, mutation: &SessionMutation) {
if let Err(e) = self.try_persist_mutation(mutation) {
self.persist_error = Some(format!("failed to persist session update: {e}"));
}
}
fn try_persist_mutation(&mut self, mutation: &SessionMutation) -> std::io::Result<()> {
if self.persist
&& let Some(file) = &self.session_file
{
let mut f = std::fs::OpenOptions::new().append(true).open(file)?;
let line = session_v4::encode_mutation(mutation);
f.write_all(line.as_bytes())?;
}
Ok(())
}
pub fn take_persist_error(&mut self) -> Option<String> {
self.persist_error.take()
}
pub fn open_operations_count(&self) -> usize {
self.state.find_open_operations(MAIN_LANE, None).len()
}
pub fn squash_stale_operations(&mut self) -> usize {
let ops = self.state.find_open_operations(MAIN_LANE, None);
if ops.len() <= 1 {
return 0;
}
let newest_seq = ops.iter().map(|r| r.seq()).max().unwrap_or(0);
let mut squashed = 0usize;
for op in ops.iter().filter(|r| r.seq() < newest_seq) {
let run_id = op.id().to_string();
self.finish_operation(&run_id);
squashed += 1;
}
squashed
}
pub fn open_operation_ids(&self) -> Vec<String> {
self.state
.find_open_operations(MAIN_LANE, None)
.iter()
.map(|r| r.id().to_string())
.collect()
}
pub fn interrupt_operations_opened_since(&mut self, before: &HashSet<String>) -> usize {
let opened: Vec<String> = self
.open_operation_ids()
.into_iter()
.filter(|id| !before.contains(id))
.collect();
for id in &opened {
self.finish_operation(id);
}
opened.len()
}
pub fn interrupt_all_open_operations(&mut self) -> usize {
let opened = self.open_operation_ids();
for id in &opened {
self.finish_operation(id);
}
opened.len()
}
fn finish_operation(&mut self, run_id: &str) {
if !self.open_operation_ids().iter().any(|id| id == run_id) {
return;
}
self.commit_record(LaneRecord::OperationFinished {
base: RecordBase {
id: new_record_id("op-fin"),
seq: 0,
lane: MAIN_LANE.to_string(),
timestamp: now_ms() as i64,
},
run_id: run_id.to_string(),
outcome: "interrupted".to_string(),
error: None,
});
}
fn commit_fact(&mut self, mut m: SessionMutation) {
if let SessionMutation::Fact { seq, .. } = &mut m {
*seq = self.state.next_sequence();
} else {
return;
}
self.persist_mutation(&m);
_ = self.state.apply_mutation(m);
self.name = self.state.get_name().map(|s| s.to_string());
}
pub fn append_message(&mut self, message: &AgentMessage) -> String {
self.commit_entry_lane(Entry::Message {
base: EntryBase {
id: new_session_id(),
seq: 0,
timestamp: 0,
parent_id: None,
},
message: message.clone(),
terminate: None,
})
}
pub fn append_custom_entry(&mut self, custom_type: &str, data: Option<Value>) -> String {
self.commit_entry_lane(Entry::Custom {
base: EntryBase {
id: new_session_id(),
seq: 0,
timestamp: 0,
parent_id: None,
},
custom_type: custom_type.to_string(),
data,
})
}
pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
self.commit_entry_lane(Entry::ModelChange {
base: EntryBase {
id: new_session_id(),
seq: 0,
timestamp: 0,
parent_id: None,
},
provider: provider.to_string(),
model_id: model_id.to_string(),
})
}
pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
self.commit_entry_lane(Entry::ThinkingLevelChange {
base: EntryBase {
id: new_session_id(),
seq: 0,
timestamp: 0,
parent_id: None,
},
thinking_level: thinking_level.to_string(),
})
}
pub fn append_active_tools_change(&mut self, tools: &[String]) -> String {
self.commit_entry_lane(Entry::ActiveToolsChange {
base: EntryBase {
id: new_session_id(),
seq: 0,
timestamp: 0,
parent_id: None,
},
active_tool_names: tools.to_vec(),
})
}
pub fn append_context_edit(
&mut self,
target_id: &str,
replacement: Option<ContextEditReplacement>,
) -> Result<String> {
let Some(target) = self
.state
.get_entries()
.iter()
.find(|e| e.id() == target_id)
else {
return Err(Error::EntryNotFound(target_id.to_string()));
};
let on_branch = self
.state
.require_lane(MAIN_LANE)
.ok()
.flatten()
.map(|leaf| {
self.state
.find_entries_on_branch(&leaf, None, None, true)
.map(|entries| entries.iter().any(|e| e.id() == target_id))
.unwrap_or(false)
})
.unwrap_or(false);
if !on_branch {
return Err(Error::msg(format!(
"context edit target {target_id} is not on the active branch"
)));
}
let editable = matches!(
target,
Entry::Message { message, .. }
if matches!(message.role.as_str(), "user" | "assistant" | "toolResult")
);
if !editable {
return Err(Error::msg(format!(
"context edit target {target_id} does not contribute editable model content"
)));
}
Ok(self.commit_entry_lane(Entry::ContextEdit {
base: EntryBase {
id: new_session_id(),
seq: 0,
timestamp: 0,
parent_id: None,
},
target_id: target_id.to_string(),
replacement,
}))
}
pub fn append_retain_none_compaction(
&mut self,
summary: &str,
tokens_before: u64,
details: Option<Value>,
usage: Option<&Usage>,
) -> String {
self.append_compaction(summary, tokens_before, None, details, usage)
}
pub fn append_lane_record(&mut self, record: &LaneRecord) -> String {
self.commit_record(record.clone())
}
pub fn append_cache_warm_usage(
&mut self,
usage: &Usage,
provider: &str,
model: &str,
) -> String {
self.append_lane_record(&LaneRecord::UsageRecord {
base: session_v4::RecordBase {
id: new_session_id(),
seq: 0,
lane: MAIN_LANE.to_string(),
timestamp: 0,
},
usage: usage.clone(),
cause: "cache_warm".to_string(),
provider: Some(provider.to_string()),
model: Some(model.to_string()),
run_id: None,
entry_id: None,
attempt: None,
stop_reason: None,
tool_call_id: None,
})
}
pub fn append_compaction(
&mut self,
summary: &str,
tokens_before: u64,
first_kept_entry_id: Option<&str>,
details: Option<Value>,
usage: Option<&Usage>,
) -> String {
let id = new_session_id();
self.commit_entry_lane(Entry::Compaction {
base: EntryBase {
id: id.clone(),
seq: 0,
timestamp: 0,
parent_id: None,
},
summary: summary.to_string(),
first_kept_entry_id: Some(first_kept_entry_id.unwrap_or(&id).to_string()),
tokens_before,
details,
usage: usage.cloned(),
})
}
pub fn append_branch_summary(
&mut self,
from_id: &str,
summary: &str,
details: Option<Value>,
usage: Option<&Usage>,
) -> Result<String> {
if !from_id.is_empty()
&& from_id != "root"
&& !self.state.get_entries().iter().any(|e| e.id() == from_id)
{
return Err(Error::EntryNotFound(from_id.to_string()));
}
Ok(self.commit_entry_lane(Entry::BranchSummary {
base: EntryBase {
id: new_session_id(),
seq: 0,
timestamp: 0,
parent_id: None,
},
from_id: from_id.to_string(),
summary: summary.to_string(),
details,
usage: usage.cloned(),
}))
}
pub fn append_session_info(&mut self, name: &str) {
self.commit_fact(SessionMutation::Fact {
seq: 0,
fact: "name".to_string(),
name: Some(name.to_string()),
target_id: None,
label: None,
});
}
pub fn append_label_change(&mut self, target_id: &str, label: Option<&str>) -> Result<()> {
if !self.state.get_entries().iter().any(|e| e.id() == target_id) {
return Err(Error::EntryNotFound(target_id.to_string()));
}
self.commit_fact(SessionMutation::Fact {
seq: 0,
fact: "label".to_string(),
name: None,
target_id: Some(target_id.to_string()),
label: label.map(|s| s.to_string()),
});
Ok(())
}
pub fn set_leaf(&mut self, id: Option<&str>) -> Result<()> {
if let Some(id) = id
&& !self.state.get_entries().iter().any(|e| e.id() == id)
{
return Err(Error::EntryNotFound(id.to_string()));
}
let mut m = SessionMutation::Lane {
seq: 0,
lane: MAIN_LANE.to_string(),
leaf_id: id.map(|s| s.to_string()),
};
if let SessionMutation::Lane { seq, .. } = &mut m {
*seq = self.state.next_sequence();
}
self.persist_mutation(&m);
_ = self.state.apply_mutation(m);
Ok(())
}
pub fn rewind_last_user_input(&mut self) -> Result<Option<String>> {
let Some(leaf) = self.state.require_lane(MAIN_LANE).ok().flatten() else {
return Ok(None);
};
let path = self
.state
.find_entries_on_branch(&leaf, None, None, true)
.map_err(|e| Error::msg(format!("failed to read session branch: {e}")))?;
let target = path.iter().rev().find_map(|entry| match entry {
Entry::Message { message, .. } if message.role == "user" => {
Some((entry_base(entry).parent_id.clone(), message.text()))
}
_ => None,
});
let Some((parent, text)) = target else {
return Ok(None);
};
self.set_leaf(parent.as_deref())?;
Ok(Some(text))
}
pub fn get_branch(&self, leaf_id: &str) -> Vec<Value> {
self.state
.find_entries_on_branch(leaf_id, None, None, true)
.map(|entries| entries.iter().map(entry_to_json).collect())
.unwrap_or_default()
}
pub fn serialize_branch_jsonl(&self) -> String {
let mut out = self.header.encode();
if let Ok(mutations) = self.state.branch_mutations(MAIN_LANE) {
for m in &mutations {
out.push_str(&session_v4::encode_mutation(m));
}
}
out
}
pub fn build_context_messages(&self) -> Vec<AgentMessage> {
self.build_context_projection()
.into_iter()
.flat_map(|p| p.messages)
.collect()
}
pub fn build_context_projection(&self) -> Vec<ProjectedContextEntry> {
if let Err(e) = self.reduce_lane() {
eprintln!("session recovery rejected (corruption): {e}");
return Vec::new();
}
let Some(leaf) = self.state.require_lane(MAIN_LANE).ok().flatten() else {
return Vec::new();
};
let path = match self.state.find_entries_on_branch(&leaf, None, None, true) {
Ok(p) => p,
Err(_) => return Vec::new(),
};
if path.is_empty() {
return Vec::new();
}
let newest = path
.iter()
.rposition(|e| matches!(e, Entry::Compaction { .. }));
let mut context: Vec<&Entry> = Vec::with_capacity(path.len());
match newest {
Some(ci) => {
context.push(&path[ci]);
let start = match &path[ci] {
Entry::Compaction {
first_kept_entry_id,
..
} => first_kept_entry_id
.as_deref()
.and_then(|id| path[..ci].iter().position(|e| e.id() == id)),
_ => None,
};
if let Some(s) = start {
context.extend(path[s..ci].iter());
}
context.extend(path[ci + 1..].iter());
}
None => context.extend(path.iter()),
}
let mut edits: HashMap<&str, &Option<ContextEditReplacement>> = HashMap::new();
for entry in &context {
if let Entry::ContextEdit {
target_id,
replacement,
..
} = entry
{
edits.insert(target_id.as_str(), replacement);
}
}
let mut result: Vec<ProjectedContextEntry> = Vec::with_capacity(context.len());
for (i, entry) in context.iter().enumerate() {
let messages = match entry {
Entry::Message { message, .. } => {
if message.role == "assistant"
&& message.stop_reason.as_deref() == Some("deferred")
{
Vec::new()
} else {
let mut m = message.clone();
m.entry_id = Some(entry.id().to_string());
match edits.get(entry.id()) {
Some(edit) => apply_context_edit(&m, edit).into_iter().collect(),
None => vec![m],
}
}
}
Entry::Compaction { summary, usage, .. } => {
let mut msgs = Vec::new();
if i == 0 {
msgs.push(Self::summary_message(
"compactionSummary",
summary,
usage.as_ref(),
));
}
msgs
}
Entry::BranchSummary { summary, usage, .. } => {
vec![Self::summary_message(
"branchSummary",
summary,
usage.as_ref(),
)]
}
_ => Vec::new(),
};
result.push(ProjectedContextEntry {
entry_id: entry.id().to_string(),
messages,
});
}
result
}
pub fn first_kept_entry_id_for_cut(
&self,
messages: &[AgentMessage],
cut_index: usize,
) -> Option<String> {
let projection = self.build_context_projection();
let mut projected = projection
.iter()
.flat_map(|p| p.messages.iter().map(move |m| (p.entry_id.as_str(), m)));
let mut cur = projected.next();
for (i, m) in messages.iter().enumerate() {
let (entry_id, projected_msg) = cur?;
if !same_message(m, projected_msg) {
continue; }
if i == cut_index {
return Some(entry_id.to_string());
}
cur = projected.next();
}
None
}
pub fn reduce_lane(&self) -> Result<LaneReductionResult> {
let entries = self.state.entries().to_vec();
let records = self.state.records().to_vec();
let open_operations = self.state.find_open_operations(MAIN_LANE, None);
let leaf_id = self.state.require_lane(MAIN_LANE).ok().flatten();
let cfg_entries = entries.clone();
Ok(reduce_lane_state(LaneReductionInput {
lane: MAIN_LANE.to_string(),
leaf_id,
open_operations,
records,
entries,
own_entries: vec![],
configuration_entries: cfg_entries,
defaults: EffectiveLaneConfiguration::default(),
})?)
}
pub fn effective_config(&self) -> EffectiveLaneConfiguration {
self.reduce_lane()
.map(|r| r.effective_configuration)
.unwrap_or_default()
}
pub fn get_tree(&self) -> Value {
let labels: HashMap<String, String> = self
.state
.entries()
.iter()
.filter_map(|e| {
self.state
.get_label(e.id())
.map(|l| (e.id().to_string(), l.to_string()))
})
.collect();
let mut children: HashMap<String, Vec<&Entry>> = HashMap::new();
let mut roots: Vec<&Entry> = Vec::new();
for e in self.state.entries() {
let parent = match e {
Entry::Message { base, .. }
| Entry::ModelChange { base, .. }
| Entry::ThinkingLevelChange { base, .. }
| Entry::ActiveToolsChange { base, .. }
| Entry::Compaction { base, .. }
| Entry::BranchSummary { base, .. }
| Entry::ContextEdit { base, .. }
| Entry::Custom { base, .. } => &base.parent_id,
};
match parent {
None => roots.push(e),
Some(p) if p.is_empty() || p == "null" => roots.push(e),
Some(pid) => children.entry(pid.clone()).or_default().push(e),
}
}
for list in children.values_mut() {
list.sort_by(|a, b| {
let ta = entry_timestamp(a);
let tb = entry_timestamp(b);
ta.cmp(&tb).then_with(|| a.id().cmp(b.id()))
});
}
let leaf_id = self.state.require_lane(MAIN_LANE).ok().flatten();
let tree: Vec<Value> = roots
.iter()
.map(|e| Self::build_node(e, &children, &labels))
.collect();
json!({ "tree": tree, "leafId": leaf_id })
}
#[stacksafe::stacksafe]
fn build_node(
entry: &Entry,
children: &HashMap<String, Vec<&Entry>>,
labels: &HashMap<String, String>,
) -> Value {
let id = entry.id().to_string();
let mut obj = serde_json::Map::new();
obj.insert("entry".into(), entry_to_json(entry));
let child_values: Vec<Value> = children
.get(&id)
.map(|list| {
list.iter()
.map(|c| Self::build_node(c, children, labels))
.collect()
})
.unwrap_or_default();
obj.insert("children".into(), Value::Array(child_values));
if let Some(label) = labels.get(&id) {
obj.insert("label".into(), json!(label));
}
Value::Object(obj)
}
pub fn get_entries(&self) -> Vec<Value> {
let mut out = Vec::new();
for e in self.state.entries() {
out.push(entry_to_json(e));
}
for r in self.state.records() {
out.push(serde_json::to_value(r).unwrap_or(Value::Null));
}
out
}
pub fn get_header(&self) -> Option<Value> {
serde_json::to_value(&self.header).ok()
}
pub fn get_leaf_id(&self) -> Option<&str> {
self.state.lane_leaf(MAIN_LANE)
}
pub fn last_active_tools(&self) -> Option<Vec<String>> {
self.state.entries().iter().rev().find_map(|e| match e {
Entry::ActiveToolsChange {
active_tool_names, ..
} => Some(active_tool_names.clone()),
_ => None,
})
}
pub fn clone_target_id(&self) -> Option<String> {
let mut current = self.state.lane_leaf(MAIN_LANE)?.to_string();
loop {
match self.state.get_entry(¤t) {
Some(Entry::Message { .. }) => return Some(current),
Some(entry) => current = entry_base(entry).parent_id.clone()?,
None => return None,
}
}
}
fn summary_message(role: &str, summary: &str, usage: Option<&Usage>) -> AgentMessage {
let mut m = AgentMessage::user_text(summary);
m.role = role.to_string();
m.usage = usage.cloned();
m
}
}
fn entry_timestamp(entry: &Entry) -> i64 {
match entry {
Entry::Message { base, .. }
| Entry::ModelChange { base, .. }
| Entry::ThinkingLevelChange { base, .. }
| Entry::ActiveToolsChange { base, .. }
| Entry::Compaction { base, .. }
| Entry::BranchSummary { base, .. }
| Entry::ContextEdit { base, .. }
| Entry::Custom { base, .. } => base.timestamp,
}
}
fn apply_context_edit(
message: &AgentMessage,
replacement: &Option<ContextEditReplacement>,
) -> Option<AgentMessage> {
let rep = replacement.as_ref()?;
let mut edited = message.clone();
edited.content = rep.content.clone();
Some(edited)
}
pub struct ProjectedContextEntry {
pub entry_id: String,
pub messages: Vec<AgentMessage>,
}
fn same_message(a: &AgentMessage, b: &AgentMessage) -> bool {
match (a.entry_id.as_deref(), b.entry_id.as_deref()) {
(Some(x), Some(y)) => x == y,
_ => a.role == b.role && a.text() == b.text(),
}
}
fn entry_to_json(entry: &Entry) -> Value {
serde_json::to_value(entry).unwrap_or(Value::Null)
}
pub fn encode_cwd_dir(cwd: &str) -> String {
let trimmed = strip_verbatim_prefix(cwd);
let trimmed = trimmed.trim_start_matches(['/', '\\']);
let safe = trimmed.replace(['/', '\\', ':'], "-");
format!("--{}--", safe)
}
pub fn default_session_dir(cwd: &str, agent_dir: &Path) -> PathBuf {
agent_dir.join("sessions").join(encode_cwd_dir(cwd))
}
static LAST_RECORD_MS: AtomicU64 = AtomicU64::new(u64::MAX);
static SAME_MS_SEQ: AtomicU64 = AtomicU64::new(0);
pub fn new_record_id(prefix: &str) -> String {
let ms = now_ms();
let prev = LAST_RECORD_MS.swap(ms, Ordering::Relaxed);
let n = if prev == ms || ms < prev {
SAME_MS_SEQ.fetch_add(1, Ordering::Relaxed)
} else {
SAME_MS_SEQ.store(1, Ordering::Relaxed);
return format!("{prefix}-{ms}");
};
format!("{prefix}-{ms}-{n}")
}
fn new_session_id() -> String {
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0) as u64;
let mut rand = [0u8; 10];
if getrandom::fill(&mut rand).is_ok() {
} else {
let seed = t ^ (std::process::id() as u64);
for (i, b) in rand.iter_mut().enumerate() {
*b = ((seed >> ((i % 8) * 8))
^ (seed
.wrapping_mul(6364136223846793005)
.wrapping_add(i as u64))) as u8;
}
}
let mut bytes = [0u8; 16];
bytes[0..6].copy_from_slice(&t.to_be_bytes()[2..8]);
bytes[6..16].copy_from_slice(&rand);
bytes[6] = (bytes[6] & 0x0F) | 0x70; bytes[8] = (bytes[8] & 0x3F) | 0x80; let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
)
}
pub fn is_valid_session_id(id: &str) -> bool {
let b = id.as_bytes();
if b.len() != 36 {
return false;
}
let dash_positions = [8usize, 13, 18, 23];
for (i, &c) in b.iter().enumerate() {
if dash_positions.contains(&i) {
if c != b'-' {
return false;
}
} else if !c.is_ascii_hexdigit() {
return false;
}
}
b[14] == b'7'
}
fn session_file_name(created_at: i64, id: &str) -> String {
let ts = chrono_ms_to_iso(created_at).replace([':', '.'], "-");
format!("{ts}_{id}.jsonl")
}
fn session_ordering_key(path: &Path) -> (u128, u64) {
let mtime_ns = std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or(0);
let header_ms = read_header_timestamp_ms(path).unwrap_or(0);
(mtime_ns, header_ms)
}
fn read_header_cwd(path: &Path) -> Option<String> {
let line = read_first_line(path)?;
let v: Value = serde_json::from_str(&line).ok()?;
v.get("cwd").and_then(|c| c.as_str()).map(|c| c.to_string())
}
pub fn session_parent_id(path: &Path) -> Option<String> {
let line = read_first_line(path)?;
let header: JsonlV4Header = serde_json::from_str(&line).ok()?;
header.parent_session_id
}
fn read_header_timestamp_ms(path: &Path) -> Option<u64> {
let first_line = std::fs::File::open(path).ok()?;
let reader = std::io::BufReader::new(first_line);
let line = reader.lines().next()?.ok()?;
let v: Value = serde_json::from_str(&line).ok()?;
let ts = v.get("createdAt")?.as_i64()?;
if ts >= 0 { Some(ts as u64) } else { None }
}
pub fn find_most_recent_session(dir: &Path) -> Option<PathBuf> {
let mut most_recent: Option<((u128, u64), PathBuf)> = None;
if let Ok(read_dir) = std::fs::read_dir(dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let order = session_ordering_key(&path);
if most_recent
.as_ref()
.map(|(o, _)| order > *o)
.unwrap_or(true)
{
most_recent = Some((order, path));
}
}
}
most_recent.map(|(_, p)| p)
}
pub fn list_sessions(dir: &Path) -> Vec<PathBuf> {
let mut sessions: Vec<((u128, u64), PathBuf)> = Vec::new();
if let Ok(read_dir) = std::fs::read_dir(dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
sessions.push((session_ordering_key(&path), path));
}
}
sessions.sort_by(|a, b| {
b.0.cmp(&a.0)
.then_with(|| b.1.as_os_str().cmp(a.1.as_os_str()))
});
sessions.into_iter().map(|(_, p)| p).collect()
}
#[derive(Debug, Clone)]
pub struct SessionMeta {
pub name: Option<String>,
pub first_message: String,
pub message_count: usize,
pub modified_age: String,
}
pub fn session_meta(path: &Path) -> Option<SessionMeta> {
let s = Session::open(&path.to_string_lossy()).ok()?;
let mut first_message = String::new();
let mut message_count = 0usize;
for e in s.state.entries() {
if let session_v4::Entry::Message { message, .. } = e {
message_count += 1;
if first_message.is_empty() && message.role == "user" {
first_message = message.text().replace(['\r', '\n'], " ").trim().to_string();
}
}
}
let modified_age = std::fs::metadata(path)
.and_then(|m| m.modified())
.map(format_session_age)
.unwrap_or_default();
Some(SessionMeta {
name: s.name.clone(),
first_message,
message_count,
modified_age,
})
}
pub(crate) fn format_session_age(modified: SystemTime) -> String {
let diff = SystemTime::now()
.duration_since(modified)
.unwrap_or_default()
.as_secs();
let mins = diff / 60;
let hours = diff / 3600;
let days = diff / 86400;
if mins < 1 {
"now".to_string()
} else if mins < 60 {
format!("{}m", mins)
} else if hours < 24 {
format!("{}h", hours)
} else if days < 7 {
format!("{}d", days)
} else if days < 30 {
format!("{}w", days / 7)
} else if days < 365 {
format!("{}mo", days / 30)
} else {
format!("{}y", days / 365)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::provider::{AgentMessage, ContentBlock};
fn user(text: &str) -> AgentMessage {
AgentMessage::user_text(text)
}
fn session_at(dir: &Path) -> Session {
let d = dir.join("sessions");
Session::create(dir.to_str().unwrap(), Some(d.clone()), true).unwrap()
}
#[test]
fn encode_cwd_dir_strips_windows_verbatim_prefix() {
assert_eq!(
encode_cwd_dir(r"\\?\C:\target\debug"),
"--C--target-debug--"
);
assert_eq!(
encode_cwd_dir(r"\\?\UNC\srv\share\proj"),
"--srv-share-proj--"
);
assert_eq!(encode_cwd_dir(r"\\?\C:\a\b"), encode_cwd_dir(r"C:\a\b"));
for cwd in [r"\\?\C:\a\b", r"\\?\UNC\srv\share", "/home/blue/repo"] {
let name = encode_cwd_dir(cwd);
assert!(
!name.contains(['?', '*', '"', '<', '>', '|']),
"{cwd} 编码出非法文件名: {name}"
);
}
assert_eq!(encode_cwd_dir("/home/blue/repo"), "--home-blue-repo--");
}
#[test]
fn create_with_is_the_single_construction_path() {
let dir = tempfile::tempdir().unwrap();
let sess = dir.path().join("sessions");
let a = Session::create_with(SessionCreateOptions {
cwd: "/tmp/a".to_string(),
session_dir: Some(sess.clone()),
persist: true,
..Default::default()
})
.unwrap();
let b = Session::create("/tmp/a", Some(sess.clone()), true).unwrap();
let c = Session::create_with_id("/tmp/a", Some(sess.clone()), true, &a.session_id).unwrap();
assert_eq!(a.cwd, "/tmp/a");
assert!(a.session_file.is_some());
assert_ne!(a.session_id, b.session_id);
assert_eq!(a.session_id, c.session_id);
assert_eq!(a.header.parent_session_id, None);
assert_eq!(c.header.parent_session_id, None);
assert_eq!(c.header.cwd, a.header.cwd);
}
#[test]
fn create_with_persists_parent_session_id() {
let dir = tempfile::tempdir().unwrap();
let sess = dir.path().join("sessions");
let parent = Session::create("/tmp/p", Some(sess.clone()), true).unwrap();
let child = Session::create_with(SessionCreateOptions {
cwd: "/tmp/p".to_string(),
session_dir: Some(sess.clone()),
persist: true,
parent_session_id: Some(parent.session_id.clone()),
..Default::default()
})
.unwrap();
let path = child.session_file.clone().unwrap();
let reopened = Session::open(&path.to_string_lossy()).unwrap();
assert_eq!(
reopened.parent_session_id(),
Some(parent.session_id.as_str())
);
let listed = list_sessions(&sess);
assert_eq!(listed.len(), 2, "{listed:?}");
}
#[test]
fn fork_header_exposes_parent_session_id() {
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_string_lossy().to_string();
let sessions = dir.path().join("sessions");
let parent = Session::create(&cwd, Some(sessions.clone()), true).unwrap();
let parent_file = parent.get_session_file().unwrap().to_path_buf();
let forked =
Session::fork_from(&parent_file.to_string_lossy(), &cwd, Some(sessions.clone()))
.unwrap();
let fork_file = forked.get_session_file().unwrap();
assert_eq!(
session_parent_id(fork_file).as_deref(),
Some(parent.session_id.as_str()),
"fork 文件头应记录父会话 id"
);
let plain = Session::create(&cwd, Some(sessions), true).unwrap();
assert_eq!(session_parent_id(plain.get_session_file().unwrap()), None);
}
#[test]
fn create_with_without_persist_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let sess = dir.path().join("sessions-none");
let s = Session::create_with(SessionCreateOptions {
cwd: "/tmp/n".to_string(),
session_dir: Some(sess.clone()),
persist: false,
..Default::default()
})
.unwrap();
assert!(s.session_file.is_none());
assert!(!sess.exists());
}
#[test]
fn create_with_rejects_invalid_session_id() {
let dir = tempfile::tempdir().unwrap();
let err = Session::create_with(SessionCreateOptions {
cwd: "/tmp/x".to_string(),
session_dir: Some(dir.path().join("s")),
persist: true,
session_id: Some("not-a-uuid".to_string()),
..Default::default()
})
.unwrap_err();
assert!(format!("{err}").contains("Invalid session id"), "{err}");
}
#[test]
fn new_record_id_unique_within_same_ms() {
let ids: Vec<String> = (0..16)
.map(|i| new_record_id(if i % 2 == 0 { "tool" } else { "step" }))
.collect();
let unique: std::collections::HashSet<&String> = ids.iter().collect();
assert_eq!(unique.len(), ids.len(), "ids must be unique: {ids:?}");
for (i, id) in ids.iter().enumerate() {
let prefix = if i % 2 == 0 { "tool" } else { "step" };
let Some(suffix) = id.strip_prefix(&format!("{prefix}-")) else {
panic!("bad prefix in {id}");
};
assert!(
suffix.chars().all(|c| c == '-' || c.is_ascii_digit()),
"bad suffix in {id}"
);
}
}
#[test]
fn create_immediately_persists_v4_header() {
let dir = tempfile::tempdir().unwrap();
let s = session_at(dir.path());
assert!(s.is_persisted());
assert!(
s.get_session_file().is_some(),
"v4 create 即落盘 header 文件"
);
assert_eq!(s.get_leaf_id(), None, "新建会话 main lane 为空");
let first = std::fs::read_to_string(s.get_session_file().unwrap()).unwrap();
let v: Value = serde_json::from_str(first.lines().next().unwrap()).unwrap();
assert_eq!(v["kind"], "header");
assert_eq!(v["version"], 4);
assert!(
v.get("createdAt").and_then(|x| x.as_i64()).is_some(),
"createdAt 为毫秒数"
);
}
#[test]
fn append_message_chains_to_lane_leaf_and_advances() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let id1 = s.append_message(&user("hi"));
assert_eq!(s.get_leaf_id(), Some(id1.as_str()));
let id2 = s.append_message(&user("again"));
assert_eq!(s.get_leaf_id(), Some(id2.as_str()));
let e2 = s.state.get_entry(&id2).unwrap();
let parent = match e2 {
session_v4::Entry::Message { base, .. } => base.parent_id.clone(),
_ => None,
};
assert_eq!(parent.as_deref(), Some(id1.as_str()));
let text = std::fs::read_to_string(s.get_session_file().unwrap()).unwrap();
let v: Value = serde_json::from_str(text.lines().nth(1).unwrap()).unwrap();
assert_eq!(v["kind"], "entry");
assert_eq!(v["lane"], "main");
}
#[test]
fn context_edit_omits_target_from_rebuilt_context_and_persists() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("hello"));
let mut assistant = AgentMessage::user_text("failed attempt");
assistant.role = "assistant".into();
assistant.stop_reason = Some("error".into());
let failed = s.append_message(&assistant);
s.append_message(&user("after"));
assert_eq!(s.build_context_messages().len(), 3);
let edit = s.append_context_edit(&failed, None).unwrap();
assert_eq!(s.get_leaf_id(), Some(edit.as_str()), "编辑自身成为新叶子");
let msgs = s.build_context_messages();
assert_eq!(msgs.len(), 2, "被省略的条目不再进入模型上下文");
assert!(
!msgs
.iter()
.any(|m| m.entry_id.as_deref() == Some(failed.as_str())),
"原始 transcript 保留、但投影中不出现"
);
assert!(s.state.get_entry(&failed).is_some());
let path = s.get_session_file().unwrap().to_string_lossy().to_string();
let reopened = Session::open(&path).unwrap();
let msgs = reopened.build_context_messages();
assert_eq!(msgs.len(), 2, "resume 后省略仍生效");
assert!(
!msgs
.iter()
.any(|m| m.entry_id.as_deref() == Some(failed.as_str()))
);
}
#[test]
fn context_edit_replaces_content_and_latest_edit_wins() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let target = s.append_message(&user("original"));
s.append_context_edit(
&target,
Some(ContextEditReplacement {
content: vec![ContentBlock::Text {
text: "rewritten".into(),
text_signature: None,
}],
}),
)
.unwrap();
let msgs = s.build_context_messages();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].text(), "rewritten");
assert_eq!(msgs[0].role, "user", "只替换 content,role 等元数据保留");
s.append_context_edit(&target, None).unwrap();
assert!(s.build_context_messages().is_empty(), "最新编辑为省略");
}
#[test]
fn context_edit_before_latest_compaction_has_no_effect() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let old = s.append_message(&user("old turn"));
let edit = s.append_context_edit(&old, None).unwrap();
s.append_retain_none_compaction("sum", 10, None, None);
assert_eq!(
s.build_context_messages().len(),
1,
"投影 = [compactionSummary]"
);
let path = s.get_session_file().unwrap().to_string_lossy().to_string();
let reopened = Session::open(&path).unwrap();
let msgs = reopened.build_context_messages();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "compactionSummary");
assert!(
reopened
.get_entries()
.iter()
.any(|e| e["id"] == edit.as_str())
);
}
fn texts(s: &Session) -> Vec<String> {
s.build_context_messages()
.iter()
.map(|m| m.text())
.collect()
}
fn replace_with(text: &str) -> Option<ContextEditReplacement> {
Some(ContextEditReplacement {
content: vec![ContentBlock::Text {
text: text.to_string(),
text_signature: None,
}],
})
}
#[test]
fn context_edit_after_compaction_applies_to_retained_entry_and_survives_reopen() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("summarized"));
let retained = s.append_message(&user("original retained"));
s.append_compaction("sum", 100, Some(&retained), None, None);
assert_eq!(texts(&s), vec!["sum", "original retained"]);
s.append_context_edit(&retained, replace_with("edited retained"))
.unwrap();
assert_eq!(texts(&s), vec!["sum", "edited retained"]);
let path = s.get_session_file().unwrap().to_string_lossy().to_string();
let reopened = Session::open(&path).unwrap();
assert_eq!(
texts(&reopened),
vec!["sum", "edited retained"],
"resume 后编辑必须仍生效(保留条目 + 编辑条目都在上下文条目集合里)"
);
}
#[test]
fn context_edit_before_compaction_applies_to_retained_entry() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let target = s.append_message(&user("original"));
s.append_context_edit(&target, replace_with("rewritten"))
.unwrap();
s.append_compaction("sum", 100, Some(&target), None, None);
assert_eq!(texts(&s), vec!["sum", "rewritten"]);
let path = s.get_session_file().unwrap().to_string_lossy().to_string();
let reopened = Session::open(&path).unwrap();
assert_eq!(texts(&reopened), vec!["sum", "rewritten"]);
}
#[test]
fn older_compaction_in_retained_range_contributes_no_summary() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let first = s.append_message(&user("first"));
s.append_compaction("sum1", 10, Some(&first), None, None);
let second = s.append_message(&user("second"));
s.append_compaction("sum2", 20, Some(&first), None, None);
assert_eq!(texts(&s), vec!["sum2", "first", "second"]);
let _ = second;
}
#[test]
fn context_edit_rejects_missing_off_branch_and_non_editable_targets() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let msg = s.append_message(&user("hi"));
assert!(s.append_context_edit("nope", None).is_err());
let tools = s.append_active_tools_change(&["read".into()]);
assert!(s.append_context_edit(&tools, None).is_err());
let branch_msg = s.append_message(&user("branch"));
s.set_leaf(Some(&msg)).unwrap();
assert!(s.append_context_edit(&branch_msg, None).is_err());
s.set_leaf(Some(&branch_msg)).unwrap();
assert!(s.append_context_edit(&branch_msg, None).is_ok());
}
#[test]
fn retain_none_compaction_keeps_only_summary() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("a"));
s.append_message(&user("b"));
s.append_retain_none_compaction("self-retaining", 42, None, None);
let msgs = s.build_context_messages();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "compactionSummary");
assert_eq!(msgs[0].text(), "self-retaining");
let path = s.get_session_file().unwrap().to_string_lossy().to_string();
let reopened = Session::open(&path).unwrap();
let msgs = reopened.build_context_messages();
assert_eq!(msgs.len(), 1, "retain-none 在 resume 后仍只留摘要");
}
#[test]
fn open_roundtrip_restores_messages_and_name() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("first"));
s.append_message(&AgentMessage {
role: "assistant".into(),
thinking_level: None,
..AgentMessage::user_text("reply")
});
s.append_session_info("my session");
let path = s.get_session_file().unwrap().to_path_buf();
drop(s);
let opened = Session::open(&path.to_string_lossy()).unwrap();
let msgs = opened.build_context_messages();
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].text(), "first");
assert_eq!(msgs[1].text(), "reply");
assert_eq!(opened.name.as_deref(), Some("my session"));
}
#[test]
fn open_rejects_seq_jump_with_broken_chain() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("m"));
let path = s.get_session_file().unwrap().to_path_buf();
drop(s);
let content = std::fs::read_to_string(&path).unwrap();
let mut lines: Vec<&str> = content.lines().collect();
let bad = "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"bad\",\"seq\":10,\"timestamp\":1,\"parentId\":null,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"x\"}]}}".to_string();
lines.insert(2, &bad);
std::fs::write(&path, lines.join("\n")).unwrap();
let err = Session::open(&path.to_string_lossy())
.unwrap_err()
.to_string();
assert!(err.contains("does not chain"), "{err}");
}
#[test]
fn open_rejects_missing_parent() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("m"));
s.append_message(&user("n"));
let path = s.get_session_file().unwrap().to_path_buf();
drop(s);
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
let bad = lines[2].replace(".parentId\":\n", ""); let lines_orig = content.lines().collect::<Vec<_>>();
let fixed = lines_orig
.iter()
.map(|l| {
if l.contains("\"type\":\"message\"") && l.contains("\"text\":\"n\"") {
l.replace_suffix_parent()
} else {
l.to_string()
}
})
.collect::<Vec<_>>();
let _ = (bad, fixed);
let mut out = String::new();
let mut seen = 0;
for l in content.lines() {
if l.contains("\"text\":\"n\"") && seen == 0 {
seen = 1;
let v: Value = serde_json::from_str(l).unwrap();
let mut map = v.as_object().unwrap().clone();
map.insert("parentId".into(), json!("phantom-missing"));
out.push_str(&serde_json::to_string(&Value::Object(map)).unwrap());
out.push('\n');
} else {
out.push_str(l);
out.push('\n');
}
}
std::fs::write(&path, out).unwrap();
let err = Session::open(&path.to_string_lossy())
.unwrap_err()
.to_string();
assert!(
err.contains("references missing parent")
|| err.contains("does not chain to the lane leaf"),
"{err}"
);
}
#[test]
fn continue_recent_skips_and_deletes_empty_sessions() {
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_str().unwrap();
let sess_dir = dir.path().join("sessions");
let mut s1 = Session::create(cwd, Some(sess_dir.clone()), true).unwrap();
s1.append_message(&user("hello"));
drop(s1);
std::thread::sleep(std::time::Duration::from_millis(2));
let s2 = Session::create(cwd, Some(sess_dir.clone()), true).unwrap();
let f2 = s2.get_session_file().unwrap().to_path_buf();
drop(s2);
std::thread::sleep(std::time::Duration::from_millis(2));
let s3 = Session::create(cwd, Some(sess_dir.clone()), true).unwrap();
let f3 = s3.get_session_file().unwrap().to_path_buf();
drop(s3);
let ret = Session::continue_recent_checked(cwd, Some(sess_dir.clone()), false);
match ret {
ContinueRecentResult::Ok(s) => {
let msgs = s.build_context_messages();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].text(), "hello");
}
_ => panic!("expected Ok with messages"),
}
assert!(!f2.exists(), "空会话 2 应被删除");
assert!(!f3.exists(), "空会话 3 应被删除");
}
#[test]
fn continue_recent_filters_other_cwd_sessions_in_explicit_dir() {
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_str().unwrap();
let other_cwd = dir.path().join("other");
std::fs::create_dir_all(&other_cwd).unwrap();
let other = other_cwd.to_str().unwrap();
let shared = dir.path().join("shared-sessions");
let mut mine = Session::create(cwd, Some(shared.clone()), true).unwrap();
mine.append_message(&user("mine"));
drop(mine);
std::thread::sleep(std::time::Duration::from_millis(2));
let theirs = Session::create(other, Some(shared.clone()), true).unwrap();
let theirs_file = theirs.get_session_file().unwrap().to_path_buf();
drop(theirs);
match Session::continue_recent_checked(cwd, Some(shared.clone()), false) {
ContinueRecentResult::Ok(s) => {
let msgs = s.build_context_messages();
assert_eq!(msgs[0].text(), "mine", "应命中本 cwd 的会话");
}
_ => panic!("expected Ok with this cwd's session"),
}
assert!(theirs_file.exists(), "其它项目的会话不应被当成空会话删除");
}
#[test]
fn continue_recent_all_empty_creates_new() {
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_str().unwrap();
let sess_dir = dir.path().join("sessions");
let s1 = Session::create(cwd, Some(sess_dir.clone()), true).unwrap();
let f1 = s1.get_session_file().unwrap().to_path_buf();
drop(s1);
let s2 = Session::create(cwd, Some(sess_dir.clone()), true).unwrap();
let f2 = s2.get_session_file().unwrap().to_path_buf();
drop(s2);
match Session::continue_recent_checked(cwd, Some(sess_dir.clone()), false) {
ContinueRecentResult::Created(_) => {}
_ => panic!("全部为空应新建"),
}
assert!(!f1.exists());
assert!(!f2.exists());
}
#[test]
fn has_user_messages_detects_empty_and_nonempty() {
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_str().unwrap();
let sess_dir = dir.path().join("sessions");
let empty = Session::create(cwd, Some(sess_dir.clone()), true).unwrap();
assert!(!empty.has_user_messages(), "新建空会话无用户消息");
drop(empty);
let mut full = Session::create(cwd, Some(sess_dir.clone()), true).unwrap();
assert!(!full.has_user_messages());
full.append_message(&user("hi"));
assert!(full.has_user_messages(), "追加用户消息后非空");
}
#[test]
fn squash_stale_operations_marks_old_runs_finished() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("hi"));
let start = |id: &str| session_v4::LaneRecord::OperationStarted {
base: session_v4::RecordBase {
id: id.into(),
seq: 0,
lane: MAIN_LANE.into(),
timestamp: 0,
},
source_leaf_id: None,
intent: session_v4::OperationIntent::Run {
original_prompt: vec![user("q")],
initial_messages: vec![],
system_prompt_override: None,
},
};
s.append_lane_record(&start("run-old-1"));
s.append_lane_record(&start("run-old-2"));
s.append_lane_record(&start("run-new"));
assert_eq!(s.open_operations_count(), 3);
let path = s.get_session_file().unwrap().to_path_buf();
let n = s.squash_stale_operations();
assert_eq!(n, 2, "除最新 op 外都应压平");
assert_eq!(s.open_operations_count(), 1);
let open = s.state.find_open_operations(MAIN_LANE, None);
assert_eq!(open.len(), 1);
assert_eq!(open[0].id(), "run-new", "保留 seq 最新的 operation");
let content = std::fs::read_to_string(&path).unwrap();
let finishes = content
.lines()
.filter(|l| l.contains("\"type\":\"operation_finished\""))
.collect::<Vec<_>>();
assert_eq!(finishes.len(), 2, "finish 记录写入文件: {content}");
assert!(
finishes.iter().all(|l| l.contains("interrupted")),
"outcome 应为 interrupted: {content}"
);
let reopened = Session::open(&path.to_string_lossy()).unwrap();
assert_eq!(reopened.open_operations_count(), 1);
assert!(reopened.reduce_lane().is_ok());
}
#[test]
fn interrupt_operations_opened_since_closes_only_new_ops() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("hi"));
let start = |id: &str| session_v4::LaneRecord::OperationStarted {
base: session_v4::RecordBase {
id: id.into(),
seq: 0,
lane: MAIN_LANE.into(),
timestamp: 0,
},
source_leaf_id: None,
intent: session_v4::OperationIntent::Run {
original_prompt: vec![user("q")],
initial_messages: vec![],
system_prompt_override: None,
},
};
s.append_lane_record(&start("inherited-zombie"));
let before: HashSet<String> = s.open_operation_ids().into_iter().collect();
s.append_lane_record(&start("round-op-1"));
s.append_lane_record(&start("round-op-2"));
assert_eq!(s.open_operations_count(), 3);
let n = s.interrupt_operations_opened_since(&before);
assert_eq!(n, 2, "只补写轮次期间新开的 op");
assert_eq!(s.open_operations_count(), 1, "继承僵尸保留原样");
let open = s.open_operation_ids();
assert_eq!(open, vec!["inherited-zombie".to_string()]);
assert_eq!(s.interrupt_operations_opened_since(&before), 0);
let path = s.get_session_file().unwrap().to_path_buf();
let content = std::fs::read_to_string(&path).unwrap();
let finishes: Vec<&str> = content
.lines()
.filter(|l| l.contains("\"type\":\"operation_finished\""))
.collect();
assert_eq!(finishes.len(), 2, "finish 记录写入文件: {content}");
assert!(finishes.iter().all(|l| l.contains("interrupted")));
let reopened = Session::open(&path.to_string_lossy()).unwrap();
assert!(reopened.reduce_lane().is_ok());
assert_eq!(reopened.open_operations_count(), 1);
}
#[test]
fn interrupt_all_open_operations_closes_everything() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("hi"));
let start = |id: &str| session_v4::LaneRecord::OperationStarted {
base: session_v4::RecordBase {
id: id.into(),
seq: 0,
lane: MAIN_LANE.into(),
timestamp: 0,
},
source_leaf_id: None,
intent: session_v4::OperationIntent::Run {
original_prompt: vec![user("q")],
initial_messages: vec![],
system_prompt_override: None,
},
};
s.append_lane_record(&start("r1"));
s.append_lane_record(&start("r2"));
assert_eq!(s.open_operations_count(), 2);
assert_eq!(s.interrupt_all_open_operations(), 2);
assert_eq!(s.open_operations_count(), 0);
assert_eq!(s.interrupt_all_open_operations(), 0, "再调为空操作");
}
#[test]
fn open_operations_count_reports_stale_runs() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("hi"));
assert_eq!(s.open_operations_count(), 0);
let start = |id: &str| session_v4::LaneRecord::OperationStarted {
base: session_v4::RecordBase {
id: id.into(),
seq: 0,
lane: MAIN_LANE.into(),
timestamp: 0,
},
source_leaf_id: None,
intent: session_v4::OperationIntent::Run {
original_prompt: vec![],
initial_messages: vec![],
system_prompt_override: None,
},
};
s.append_lane_record(&start("r1"));
s.append_lane_record(&start("r2"));
assert_eq!(s.open_operations_count(), 2, "残留 open op 计入");
}
#[test]
fn import_repairable_flow_truncates_and_reopens() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("first"));
s.append_message(&user("second"));
s.append_message(&user("third"));
let path = s.get_session_file().unwrap().to_path_buf();
drop(s);
let content = std::fs::read_to_string(&path).unwrap();
let mut lines: Vec<&str> = content.lines().collect();
lines.insert(2, "{this is not valid json}");
std::fs::write(&path, lines.join("\n")).unwrap();
let plan = match Session::open_checked(&path.to_string_lossy()) {
OpenSessionResult::Repairable { plan, .. } => plan,
_ => panic!("expected Repairable"),
};
assert_eq!(plan.total_lines, 5);
assert!(
plan.salvage_available(),
"非法 JSON 行可跳过抢救,后缀应完整保留"
);
let truncate_msgs = |filename: &str| {
let opened = Session::open(filename).unwrap();
opened.build_context_messages().len()
};
Session::repair_write(&path.to_string_lossy(), &plan, false).unwrap();
assert_eq!(truncate_msgs(&path.to_string_lossy()), 1);
Session::repair_write(&path.to_string_lossy(), &plan, true).unwrap();
let reopened = Session::open(&path.to_string_lossy()).unwrap();
let msgs = reopened.build_context_messages();
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[0].text(), "first");
assert_eq!(msgs[2].text(), "third");
assert!(path.with_extension("jsonl.bak").exists());
}
#[test]
fn salvage_duplicate_id_keeps_suffix() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("first"));
s.append_lane_record(&session_v4::LaneRecord::OperationStarted {
base: session_v4::RecordBase {
id: "run-1".into(),
seq: 0,
lane: "main".into(),
timestamp: 1,
},
source_leaf_id: None,
intent: session_v4::OperationIntent::Run {
original_prompt: vec![user("first")],
initial_messages: vec![],
system_prompt_override: None,
},
});
s.append_lane_record(&session_v4::LaneRecord::StepAttempt {
base: session_v4::RecordBase {
id: "step-1".into(),
seq: 0,
lane: "main".into(),
timestamp: 1,
},
run_id: "run-1".into(),
step: "assistant".into(),
attempt: 1,
result_entry_id: "m-none".into(),
compaction_reason: None,
});
s.append_lane_record(&session_v4::LaneRecord::OperationFinished {
base: session_v4::RecordBase {
id: "fin-1".into(),
seq: 0,
lane: "main".into(),
timestamp: 1,
},
run_id: "run-1".into(),
outcome: "completed".into(),
error: None,
});
let path = s.get_session_file().unwrap().to_path_buf();
drop(s);
let content = std::fs::read_to_string(&path).unwrap();
let mut out = String::new();
for l in content.lines() {
if l.contains("\"id\":\"step-1\"") {
out.push_str(l);
out.push('\n');
let mut dup: Value = serde_json::from_str(l).unwrap();
dup["seq"] = json!(4);
out.push_str(&serde_json::to_string(&dup).unwrap());
out.push('\n');
} else if l.contains("\"id\":\"fin-1\"") {
let mut v: Value = serde_json::from_str(l).unwrap();
v["seq"] = json!(5);
out.push_str(&serde_json::to_string(&v).unwrap());
out.push('\n');
} else {
out.push_str(l);
out.push('\n');
}
}
std::fs::write(&path, out).unwrap();
let plan = match Session::open_checked(&path.to_string_lossy()) {
OpenSessionResult::Repairable { plan, .. } => plan,
_ => panic!("expected Repairable"),
};
assert!(
plan.salvage_available(),
"重复 id 属 B 档冗余,可跳过抢救:{} ",
plan.error
);
assert_eq!(plan.salvaged_skipped, 1);
assert!(
plan.error.contains("contains duplicate id"),
"{}",
plan.error
);
Session::repair_write(&path.to_string_lossy(), &plan, true).unwrap();
let opened = Session::open_checked(&path.to_string_lossy());
assert!(
matches!(opened, OpenSessionResult::Ok(_)),
"repair 后应可正常打开"
);
let text = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(
lines.len(),
5,
"header + run-1/step-1/fin-1/message(重复行已丢弃)"
);
let fin: Value = serde_json::from_str(
lines
.iter()
.find(|l| l.contains("\"id\":\"fin-1\""))
.unwrap(),
)
.unwrap();
assert_eq!(fin["seq"], 4, "fin-1 原 seq5 应重排为 4,保持严格连续");
assert!(path.with_extension("jsonl.bak").exists());
}
#[test]
fn salvage_gate_blocks_dangling_reference() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("first"));
s.append_message(&user("second"));
s.append_message(&user("third"));
let path = s.get_session_file().unwrap().to_path_buf();
drop(s);
let content = std::fs::read_to_string(&path).unwrap();
let mut out = String::new();
for l in content.lines() {
if l.contains("\"text\":\"second\"") {
let mut v: Value = serde_json::from_str(l).unwrap();
v["seq"] = json!(0);
out.push_str(&serde_json::to_string(&v).unwrap());
} else {
out.push_str(l);
}
out.push('\n');
}
std::fs::write(&path, out).unwrap();
let plan = match Session::open_checked(&path.to_string_lossy()) {
OpenSessionResult::Repairable { plan, .. } => plan,
_ => panic!("expected Repairable"),
};
assert!(
!plan.salvage_available(),
"后缀依赖被丢弃的 id,抢救应被闸门拦下"
);
assert_eq!(plan.salvaged_skipped, 0);
Session::repair_write(&path.to_string_lossy(), &plan, false).unwrap();
let reopened = Session::open(&path.to_string_lossy()).unwrap();
let msgs = reopened.build_context_messages();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].text(), "first");
}
#[test]
fn torn_tail_auto_truncates_keeps_valid_prefix() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("first"));
s.append_message(&user("second"));
let path = s.get_session_file().unwrap().to_path_buf();
drop(s);
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
use std::io::Write;
writeln!(f, "{{\"kind\":\"entry\",\"lane\":\"main").unwrap();
drop(f);
let session = Session::open(&path.to_string_lossy()).unwrap();
let msgs = session.build_context_messages();
assert_eq!(
msgs.len(),
2,
"torn tail 自动修复后两条消息都在(不再只留 header)"
);
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 3);
let reopened = Session::open(&path.to_string_lossy()).unwrap();
assert_eq!(reopened.build_context_messages().len(), 2);
}
#[test]
fn non_consecutive_seq_is_repaired_keeps_following_entries() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("first"));
s.append_session_info("myname"); s.append_message(&user("third")); let path = s.get_session_file().unwrap().to_path_buf();
drop(s);
let content = std::fs::read_to_string(&path).unwrap();
let mut out = String::new();
for l in content.lines() {
if !l.contains("\"kind\":\"fact\"") {
out.push_str(l);
out.push('\n');
}
}
std::fs::write(&path, out).unwrap();
match Session::open_checked(&path.to_string_lossy()) {
OpenSessionResult::Ok(opened) => {
let msgs = opened.build_context_messages();
assert_eq!(
msgs.len(),
2,
"seq 跳号行内容自洽:修复后 first+third 都保留,不截断"
);
assert_eq!(msgs[0].text(), "first");
assert_eq!(msgs[1].text(), "third");
}
_ => panic!("seq 跳号(无引用断裂)应自动修复为 Ok,而不是 Repairable/截断"),
}
let text = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "header + first + third");
let mut seqs: Vec<u64> = Vec::new();
for l in &lines[1..] {
let v: Value = serde_json::from_str(l).unwrap();
seqs.push(v["seq"].as_u64().unwrap());
}
assert_eq!(seqs, vec![1, 2], "修复后磁盘 seq 必须严格连续");
assert!(path.with_extension("jsonl.bak").exists());
}
#[test]
fn seq_fix_still_fails_on_dangling_parent_truncates() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("first"));
s.append_message(&user("second"));
s.append_message(&user("third"));
let path = s.get_session_file().unwrap().to_path_buf();
drop(s);
let content = std::fs::read_to_string(&path).unwrap();
let mut out = String::new();
for l in content.lines() {
if !l.contains("\"text\":\"second\"") {
out.push_str(l);
out.push('\n');
}
}
std::fs::write(&path, out).unwrap();
let plan = match Session::open_checked(&path.to_string_lossy()) {
OpenSessionResult::Repairable { plan, .. } => plan,
_ => panic!("expected Repairable"),
};
assert!(
plan.error.contains("does not chain"),
"{}。修 seq 后真实损坏应报链条断裂而非 seq 错误",
plan.error
);
Session::repair_write(&path.to_string_lossy(), &plan, false).unwrap();
let reopened = Session::open(&path.to_string_lossy()).unwrap();
let msgs = reopened.build_context_messages();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].text(), "first");
}
#[test]
fn salvage_skips_multiple_garbage_lines_keeps_all() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("first"));
s.append_message(&user("second"));
s.append_message(&user("third"));
let path = s.get_session_file().unwrap().to_path_buf();
drop(s);
let content = std::fs::read_to_string(&path).unwrap();
let mut out = String::new();
let mut inserted = 0;
for l in content.lines() {
out.push_str(l);
out.push('\n');
if l.contains("\"text\":\"first\"") || l.contains("\"text\":\"second\"") {
inserted += 1;
out.push_str("{this is not valid json}");
out.push('\n');
}
}
assert_eq!(inserted, 2);
std::fs::write(&path, out).unwrap();
let plan = match Session::open_checked(&path.to_string_lossy()) {
OpenSessionResult::Repairable { plan, .. } => plan,
_ => panic!("expected Repairable"),
};
assert_eq!(plan.salvaged_skipped, 2, "两处垃圾行均应跳过");
assert!(plan.salvage_available());
Session::repair_write(&path.to_string_lossy(), &plan, true).unwrap();
let reopened = Session::open(&path.to_string_lossy()).unwrap();
let msgs = reopened.build_context_messages();
assert_eq!(msgs.len(), 3, "抢救保留全部三条消息");
assert_eq!(msgs[0].text(), "first");
assert_eq!(msgs[1].text(), "second");
assert_eq!(msgs[2].text(), "third");
}
#[test]
fn salvage_skips_multiple_duplicate_ids_keeps_suffix() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("first"));
s.append_lane_record(&session_v4::LaneRecord::OperationStarted {
base: session_v4::RecordBase {
id: "run-1".into(),
seq: 0,
lane: "main".into(),
timestamp: 1,
},
source_leaf_id: None,
intent: session_v4::OperationIntent::Run {
original_prompt: vec![user("first")],
initial_messages: vec![],
system_prompt_override: None,
},
});
s.append_lane_record(&session_v4::LaneRecord::StepAttempt {
base: session_v4::RecordBase {
id: "step-1".into(),
seq: 0,
lane: "main".into(),
timestamp: 1,
},
run_id: "run-1".into(),
step: "assistant".into(),
attempt: 1,
result_entry_id: "m-none".into(),
compaction_reason: None,
});
s.append_lane_record(&session_v4::LaneRecord::OperationFinished {
base: session_v4::RecordBase {
id: "fin-1".into(),
seq: 0,
lane: "main".into(),
timestamp: 1,
},
run_id: "run-1".into(),
outcome: "completed".into(),
error: None,
});
let last_id = s.append_message(&user("last"));
let path = s.get_session_file().unwrap().to_path_buf();
drop(s);
let content = std::fs::read_to_string(&path).unwrap();
let mut out = String::new();
for l in content.lines() {
out.push_str(l);
out.push('\n');
if l.contains("\"id\":\"step-1\"") {
let mut dup: Value = serde_json::from_str(l).unwrap();
dup["seq"] = json!(4);
out.push_str(&serde_json::to_string(&dup).unwrap());
out.push('\n');
} else if l.contains("\"id\":\"fin-1\"") {
let mut dup: Value = serde_json::from_str(l).unwrap();
dup["seq"] = json!(6);
out.push_str(&serde_json::to_string(&dup).unwrap());
out.push('\n');
}
}
std::fs::write(&path, out).unwrap();
let plan = match Session::open_checked(&path.to_string_lossy()) {
OpenSessionResult::Repairable { plan, .. } => plan,
_ => panic!("expected Repairable"),
};
assert_eq!(plan.salvaged_skipped, 2, "两个重复 id 都应跳过");
assert!(plan.salvage_available());
Session::repair_write(&path.to_string_lossy(), &plan, true).unwrap();
let reopened = Session::open(&path.to_string_lossy()).unwrap();
let entries: Vec<String> = reopened
.state
.entries()
.iter()
.map(|e| e.id().to_string())
.collect();
assert!(entries.contains(&last_id), "后缀消息必须保留:{entries:?}");
assert!(plan.error.contains("contains duplicate id"));
}
#[test]
fn lane_record_does_not_participate_in_tree() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let id1 = s.append_message(&user("hi"));
s.append_lane_record(&session_v4::LaneRecord::OperationStarted {
base: session_v4::RecordBase {
id: "run-1".into(),
seq: 0,
lane: "main".into(),
timestamp: 1,
},
source_leaf_id: None,
intent: session_v4::OperationIntent::Run {
original_prompt: vec![user("hi")],
initial_messages: vec![],
system_prompt_override: None,
},
});
assert_eq!(s.get_leaf_id(), Some(id1.as_str()));
let msgs = s.build_context_messages();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].text(), "hi");
}
#[test]
fn serialize_branch_jsonl_roundtrips_via_import() {
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_str().unwrap();
let sess_dir = dir.path().join("sessions");
let mut s = Session::create(cwd, Some(sess_dir.clone()), true).unwrap();
let empty = s.serialize_branch_jsonl();
assert!(
empty
.lines()
.next()
.unwrap()
.contains("\"kind\":\"header\"")
);
assert!(
!empty.contains("\"kind\":\"entry\""),
"空分支不应有条目行: {empty}"
);
let id1 = s.append_message(&user("hi"));
s.append_label_change(&id1, Some("keep")).unwrap();
s.append_active_tools_change(&["read".to_string(), "bash".to_string()]);
let jsonl = s.serialize_branch_jsonl();
let out = dir.path().join("branch.jsonl");
std::fs::write(&out, &jsonl).unwrap();
let reopened = Session::open(out.to_str().unwrap()).unwrap();
assert_eq!(
reopened.get_entries().len(),
s.get_entries().len(),
"entry 数一致"
);
assert_eq!(reopened.get_leaf_id(), s.get_leaf_id());
assert_eq!(reopened.build_context_messages().len(), 1);
assert_eq!(
find_label_in_tree(&reopened.get_tree(), &id1).as_deref(),
Some("keep"),
"label 应随分支导出"
);
}
#[test]
fn fork_tree_copies_entries_and_facts_not_records() {
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_str().unwrap();
let sess_dir = dir.path().join("sessions");
let mut s = Session::create(cwd, Some(sess_dir.clone()), true).unwrap();
let id1 = s.append_message(&user("original"));
s.append_message(&AgentMessage {
role: "assistant".into(),
thinking_level: None,
..AgentMessage::user_text("")
});
s.append_lane_record(&session_v4::LaneRecord::OperationStarted {
base: session_v4::RecordBase {
id: "run-1".into(),
seq: 0,
lane: "main".into(),
timestamp: 1,
},
source_leaf_id: None,
intent: session_v4::OperationIntent::Run {
original_prompt: vec![],
initial_messages: vec![],
system_prompt_override: None,
},
});
s.append_label_change(&id1, Some("keep")).unwrap();
s.append_session_info("name of source");
let path = s.get_session_file().unwrap().to_string_lossy().to_string();
drop(s);
let forked = Session::fork_from(&path, cwd, Some(sess_dir.clone())).unwrap();
assert_eq!(forked.state.records().len(), 0, "fork 不复制 records");
assert_eq!(forked.name.as_deref(), Some("name of source"));
let tree = forked.get_tree();
let found = find_label_in_tree(&tree, &id1);
assert_eq!(found.as_deref(), Some("keep"));
let msgs = forked.build_context_messages();
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].text(), "original");
}
#[test]
fn fork_branch_requires_message_target() {
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_str().unwrap();
let sess_dir = dir.path().join("sessions");
let mut s = Session::create(cwd, Some(sess_dir.clone()), true).unwrap();
s.append_active_tools_change(&["read".to_string()]);
let atc_id = s.state.entries().last().unwrap().id().to_string();
s.append_message(&user("hi"));
let path = s.get_session_file().unwrap().to_string_lossy().to_string();
drop(s);
let err = Session::fork_at(&path, &atc_id, cwd, Some(sess_dir.clone()))
.unwrap_err()
.to_string();
assert!(
err.contains("not a message entry"),
"fork branch 目标必须是 message: {err}"
);
}
#[test]
fn compaction_context_is_summary_plus_retained_entries() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let _old = s.append_message(&user("old"));
let kept1 = s.append_message(&user("kept1"));
let kept2 = AgentMessage {
role: "assistant".into(),
thinking_level: None,
..user("kept2")
};
s.append_message(&kept2);
s.append_compaction("## Goal\nsum", 100, Some(&kept1), None, None);
let msgs = s.build_context_messages();
let texts: Vec<String> = msgs.iter().map(|m| m.text()).collect();
assert_eq!(
texts,
vec![
"## Goal\nsum".to_string(),
"kept1".to_string(),
"kept2".to_string()
]
);
assert_eq!(msgs[1].entry_id.as_deref(), Some(kept1.as_str()));
}
#[test]
fn retain_none_compaction_self_references_first_kept_entry() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("old"));
let cid = s.append_retain_none_compaction("sum", 10, None, None);
let comp = s.state.get_entry(&cid).unwrap();
match comp {
Entry::Compaction {
first_kept_entry_id,
..
} => {
assert_eq!(first_kept_entry_id.as_deref(), Some(cid.as_str()));
}
other => panic!("expected compaction, got {other:?}"),
}
let msgs = s.build_context_messages();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "compactionSummary");
}
#[test]
fn projected_summaries_carry_entry_usage() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let usage = Usage {
input: 4_000,
output: 200,
total_tokens: 4_200,
cost: crate::core::provider::Cost {
total: 0.04,
..Default::default()
},
..Default::default()
};
s.append_message(&user("old"));
s.append_retain_none_compaction("sum", 10, None, Some(&usage));
let msgs = s.build_context_messages();
assert_eq!(msgs[0].role, "compactionSummary");
assert_eq!(
msgs[0].usage.as_ref().map(|u| u.cost.total),
Some(0.04),
"投影消息应携带压缩条目的 usage"
);
let path = s.get_session_file().unwrap().to_string_lossy().to_string();
let reopened = Session::open(&path).unwrap();
let msgs = reopened.build_context_messages();
assert_eq!(msgs[0].usage.as_ref().map(|u| u.cost.total), Some(0.04));
}
#[test]
fn set_leaf_moves_lane_and_chain() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let _id1 = s.append_message(&user("m1"));
let id2 = s.append_message(&user("m2"));
let _id3 = s.append_message(&user("m3"));
s.set_leaf(Some(&id2)).unwrap();
let id4 = s.append_message(&user("m4"));
assert_eq!(s.get_leaf_id(), Some(id4.as_str()));
let e4 = s.state.get_entry(&id4).unwrap();
let parent = match e4 {
session_v4::Entry::Message { base, .. } => base.parent_id.clone(),
_ => None,
};
assert_eq!(parent.as_deref(), Some(id2.as_str()));
let msgs = s.build_context_messages();
let texts: Vec<String> = msgs.iter().map(|m| m.text()).collect();
assert_eq!(texts, vec!["m1", "m2", "m4"]);
}
fn assistant(text: &str) -> AgentMessage {
let mut m = AgentMessage::user_text(text);
m.role = "assistant".into();
m
}
#[test]
fn rewind_last_user_input_moves_leaf_before_last_user() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let u1 = s.append_message(&user("first"));
let _a1 = s.append_message(&assistant("a1"));
let _u2 = s.append_message(&user("second"));
let a2 = s.append_message(&assistant("a2"));
let _u3 = s.append_message(&user("third"));
let _a3 = s.append_message(&assistant("a3"));
let removed = s.rewind_last_user_input().unwrap();
assert_eq!(removed.as_deref(), Some("third"));
assert_eq!(
s.get_leaf_id(),
Some(a2.as_str()),
"叶子应回到 second 的回复"
);
let texts: Vec<String> = s
.build_context_messages()
.iter()
.map(|m| m.text())
.collect();
assert_eq!(texts, vec!["first", "a1", "second", "a2"]);
assert!(s.state.get_entry(&u1).is_some());
assert!(s.state.get_entry(&_a3).is_some());
let path = s.get_session_file().unwrap().to_string_lossy().to_string();
let reopened = Session::open(&path).unwrap();
let reopened_texts: Vec<String> = reopened
.build_context_messages()
.iter()
.map(|m| m.text())
.collect();
assert_eq!(reopened_texts, vec!["first", "a1", "second", "a2"]);
assert_eq!(reopened.get_leaf_id(), Some(a2.as_str()));
}
#[test]
fn rewind_last_user_input_first_message_clears_branch() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
s.append_message(&user("only"));
s.append_message(&assistant("reply"));
assert_eq!(s.build_context_messages().len(), 2);
let removed = s.rewind_last_user_input().unwrap();
assert_eq!(removed.as_deref(), Some("only"));
assert_eq!(s.get_leaf_id(), None, "回退第一条 user 消息应清空 lane");
assert!(s.build_context_messages().is_empty());
let path = s.get_session_file().unwrap().to_string_lossy().to_string();
let reopened = Session::open(&path).unwrap();
assert_eq!(reopened.get_leaf_id(), None);
assert!(reopened.build_context_messages().is_empty());
}
#[test]
fn rewind_last_user_input_without_user_returns_none() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
assert_eq!(s.rewind_last_user_input().unwrap(), None);
let a = s.append_message(&assistant("synthetic"));
assert_eq!(s.rewind_last_user_input().unwrap(), None);
assert_eq!(s.get_leaf_id(), Some(a.as_str()), "无变化时叶子不应移动");
assert_eq!(s.build_context_messages().len(), 1);
}
#[test]
fn name_and_label_are_facts_not_entries() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let id1 = s.append_message(&user("m"));
s.append_session_info("display");
s.append_label_change(&id1, Some("lbl")).unwrap();
let entries = s.get_entries();
let entry_types: Vec<&str> = entries
.iter()
.filter_map(|e| e.get("type").and_then(|v| v.as_str()))
.collect();
assert!(!entry_types.contains(&"session_info"), "{entry_types:?}");
assert!(!entry_types.contains(&"label"), "{entry_types:?}");
let text = std::fs::read_to_string(s.get_session_file().unwrap()).unwrap();
let fact_lines: Vec<&str> = text
.lines()
.filter(|l| l.contains("\"kind\":\"fact\""))
.collect();
assert_eq!(fact_lines.len(), 2, "name + label 各一条 fact: {text}");
let tree = s.get_tree();
let found = find_label_in_tree(&tree, &id1);
assert_eq!(found.as_deref(), Some("lbl"));
}
fn find_label_in_tree(tree: &Value, target: &str) -> Option<String> {
let nodes = tree.get("tree")?.as_array()?;
fn walk(node: &Value, target: &str) -> Option<String> {
let entry_id = node
.pointer("/entry/id")
.and_then(|v| v.as_str())
.unwrap_or("");
if entry_id == target {
return node
.get("label")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
for child in node
.get("children")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
{
if let Some(l) = walk(child, target) {
return Some(l);
}
}
None
}
nodes.iter().find_map(|n| walk(n, target))
}
trait ReplaceSuffixParent {
fn replace_suffix_parent(&self) -> String;
}
impl ReplaceSuffixParent for &str {
fn replace_suffix_parent(&self) -> String {
self.to_string()
}
}
#[test]
fn fork_keeps_compaction_boundary() {
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_str().unwrap();
let sess_dir = dir.path().join("sessions");
let mut s = Session::create(cwd, Some(sess_dir.clone()), true).unwrap();
let _old = s.append_message(&user("old"));
let kept = s.append_message(&user("kept-tail"));
s.append_compaction("## Goal\nsum", 100, Some(&kept), None, None);
let id_new = s.append_message(&user("after-compaction"));
s.append_label_change(&id_new, Some("keep")).unwrap();
let path = s.get_session_file().unwrap().to_string_lossy().to_string();
drop(s);
let forked = Session::fork_from(&path, cwd, Some(sess_dir.clone())).unwrap();
let msgs = forked.build_context_messages();
let texts: Vec<String> = msgs.iter().map(|m| m.text()).collect();
assert_eq!(
texts,
vec![
"## Goal\nsum".to_string(),
"kept-tail".to_string(),
"after-compaction".to_string()
],
"fork 后上下文必须以 compaction summary + retained tail + 后续消息开头"
);
}
#[test]
fn fork_branch_keeps_compaction_boundary() {
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_str().unwrap();
let sess_dir = dir.path().join("sessions");
let mut s = Session::create(cwd, Some(sess_dir.clone()), true).unwrap();
let _old = s.append_message(&user("old"));
let kept = s.append_message(&user("kept-tail"));
s.append_compaction("## Goal\nsum", 100, Some(&kept), None, None);
let target = s.append_message(&user("after-compaction"));
let path = s.get_session_file().unwrap().to_string_lossy().to_string();
drop(s);
let forked = Session::fork_at(&path, &target, cwd, Some(sess_dir.clone())).unwrap();
let msgs = forked.build_context_messages();
let texts: Vec<String> = msgs.iter().map(|m| m.text()).collect();
assert_eq!(
texts,
vec![
"## Goal\nsum".to_string(),
"kept-tail".to_string(),
"after-compaction".to_string()
],
"branch fork 后 compaction boundary 必须保留"
);
}
#[test]
fn cache_warm_usage_is_recorded_and_counted_in_stats() {
let dir = tempfile::tempdir().unwrap();
let mut s = session_at(dir.path());
let usage = Usage {
input: 10,
output: 1,
cache_read: 1_000,
cache_write: 2_000,
total_tokens: 3_011,
cost: crate::core::provider::Cost {
total: 0.030015,
..Default::default()
},
..Default::default()
};
s.append_cache_warm_usage(&usage, "anthropic", "claude-sonnet-5-5");
let record = s
.get_entries()
.into_iter()
.find(|e| e["type"] == "usage_record")
.expect("usage_record 应出现在 get_entries 里");
assert_eq!(record["cause"], "cache_warm");
assert_eq!(record["provider"], "anthropic");
assert_eq!(record["model"], "claude-sonnet-5-5");
let stats = s.state.get_stats();
assert_eq!(stats.cached_tokens, 1_000);
assert_eq!(stats.uncached_tokens, 2_010);
assert_eq!(stats.total_tokens, 3_011);
assert!((stats.cost_total - 0.030015).abs() < 1e-12);
let text = std::fs::read_to_string(s.get_session_file().unwrap()).unwrap();
assert!(
text.lines().any(|l| {
let v: Value = serde_json::from_str(l).unwrap_or(Value::Null);
v["kind"] == "record" && v["cause"] == "cache_warm"
}),
"{text}"
);
}
}