use super::compact::CompactConfig;
use super::constants::{
DEFAULT_MAX_CONTEXT_TOKENS, DEFAULT_MAX_HISTORY_MESSAGES, DEFAULT_MAX_TOOL_ROUNDS,
MESSAGE_PREVIEW_MAX_LEN,
};
use super::theme::ThemeName;
use crate::config::YamlConfig;
use crate::error;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelProvider {
pub name: String,
pub api_base: String,
pub api_key: String,
pub model: String,
#[serde(default)]
pub supports_vision: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AgentConfig {
#[serde(default)]
pub providers: Vec<ModelProvider>,
#[serde(default)]
pub active_index: usize,
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default = "default_max_history_messages")]
pub max_history_messages: usize,
#[serde(default = "default_max_context_tokens")]
pub max_context_tokens: usize,
#[serde(default)]
pub theme: ThemeName,
#[serde(default)]
pub tools_enabled: bool,
#[serde(default = "default_max_tool_rounds")]
pub max_tool_rounds: usize,
#[serde(default)]
pub style: Option<String>,
#[serde(default)]
pub tool_confirm_timeout: u64,
#[serde(default)]
pub disabled_tools: Vec<String>,
#[serde(default)]
pub disabled_skills: Vec<String>,
#[serde(default)]
pub disabled_commands: Vec<String>,
#[serde(default)]
pub compact: CompactConfig,
#[serde(default)]
pub auto_restore_session: bool,
}
fn default_max_history_messages() -> usize {
DEFAULT_MAX_HISTORY_MESSAGES
}
fn default_max_context_tokens() -> usize {
DEFAULT_MAX_CONTEXT_TOKENS
}
fn default_max_tool_rounds() -> usize {
DEFAULT_MAX_TOOL_ROUNDS
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallItem {
pub id: String,
pub name: String,
pub arguments: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageData {
pub base64: String,
pub media_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String, #[serde(default)]
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCallItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip)]
pub images: Option<Vec<ImageData>>,
}
impl ChatMessage {
pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
Self {
role: role.into(),
content: content.into(),
tool_calls: None,
tool_call_id: None,
images: None,
}
}
#[allow(dead_code)]
pub fn with_images(
role: impl Into<String>,
content: impl Into<String>,
images: Vec<ImageData>,
) -> Self {
Self {
role: role.into(),
content: content.into(),
tool_calls: None,
tool_call_id: None,
images: if images.is_empty() {
None
} else {
Some(images)
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ChatSession {
pub messages: Vec<ChatMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SessionEvent {
Msg {
#[serde(flatten)]
message: ChatMessage,
#[serde(default, skip_serializing_if = "is_zero_u64")]
timestamp_ms: u64,
},
Clear,
Restore { messages: Vec<ChatMessage> },
}
fn is_zero_u64(v: &u64) -> bool {
*v == 0
}
pub fn current_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
impl SessionEvent {
pub fn msg(message: ChatMessage) -> Self {
Self::Msg {
message,
timestamp_ms: current_millis(),
}
}
}
pub fn agent_data_dir() -> PathBuf {
let dir = YamlConfig::data_dir().join("agent").join("data");
let _ = fs::create_dir_all(&dir);
dir
}
pub fn sessions_dir() -> PathBuf {
let dir = agent_data_dir().join("sessions");
let _ = fs::create_dir_all(&dir);
dir
}
pub fn session_file_path(session_id: &str) -> PathBuf {
SessionPaths::new(session_id).transcript()
}
#[derive(Debug)]
pub struct SessionPaths {
id: String,
dir: PathBuf,
}
impl SessionPaths {
pub fn new(session_id: &str) -> Self {
let dir = sessions_dir().join(session_id);
Self {
id: session_id.to_string(),
dir,
}
}
#[allow(dead_code)]
pub fn id(&self) -> &str {
&self.id
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn transcript(&self) -> PathBuf {
self.dir.join("transcript.jsonl")
}
pub fn meta_file(&self) -> PathBuf {
self.dir.join("session.json")
}
pub fn transcripts_dir(&self) -> PathBuf {
self.dir.join(".transcripts")
}
pub fn teammates_file(&self) -> PathBuf {
self.dir.join("teammates.json")
}
pub fn teammates_dir(&self) -> PathBuf {
self.dir.join("teammates")
}
pub fn teammate_dir(&self, sanitized_name: &str) -> PathBuf {
self.teammates_dir().join(sanitized_name)
}
pub fn teammate_transcript(&self, sanitized_name: &str) -> PathBuf {
self.teammate_dir(sanitized_name).join("transcript.jsonl")
}
pub fn teammate_todos_file(&self, sanitized_name: &str) -> PathBuf {
self.teammate_dir(sanitized_name).join("todos.json")
}
pub fn subagents_file(&self) -> PathBuf {
self.dir.join("subagents.json")
}
pub fn subagents_dir(&self) -> PathBuf {
self.dir.join("subagents")
}
pub fn subagent_dir(&self, sub_id: &str) -> PathBuf {
self.subagents_dir().join(sub_id)
}
pub fn subagent_transcript(&self, sub_id: &str) -> PathBuf {
self.subagent_dir(sub_id).join("transcript.jsonl")
}
pub fn subagent_todos_file(&self, sub_id: &str) -> PathBuf {
self.subagent_dir(sub_id).join("todos.json")
}
pub fn tasks_file(&self) -> PathBuf {
self.dir.join("tasks.json")
}
pub fn todos_file(&self) -> PathBuf {
self.dir.join("todos.json")
}
pub fn plan_file(&self) -> PathBuf {
self.dir.join("plan.json")
}
pub fn skills_file(&self) -> PathBuf {
self.dir.join("skills.json")
}
pub fn hooks_file(&self) -> PathBuf {
self.dir.join("hooks.json")
}
pub fn sandbox_file(&self) -> PathBuf {
self.dir.join("sandbox.json")
}
pub fn ensure_dir(&self) -> std::io::Result<()> {
fs::create_dir_all(&self.dir)
}
}
pub fn agent_config_path() -> PathBuf {
agent_data_dir().join("agent_config.json")
}
pub fn system_prompt_path() -> PathBuf {
agent_data_dir().join("system_prompt.md")
}
pub fn style_path() -> PathBuf {
agent_data_dir().join("style.md")
}
pub fn memory_path() -> PathBuf {
agent_data_dir().join("memory.md")
}
pub fn soul_path() -> PathBuf {
agent_data_dir().join("soul.md")
}
pub fn hooks_config_path() -> PathBuf {
let dir = YamlConfig::data_dir().join("agent");
let _ = fs::create_dir_all(&dir);
dir.join("hooks.yaml")
}
pub fn load_agent_config() -> AgentConfig {
let path = agent_config_path();
if !path.exists() {
return AgentConfig::default();
}
match fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
error!("✖️ 解析 agent_config.json 失败: {}", e);
AgentConfig::default()
}),
Err(e) => {
error!("✖️ 读取 agent_config.json 失败: {}", e);
AgentConfig::default()
}
}
}
pub fn save_agent_config(config: &AgentConfig) -> bool {
let path = agent_config_path();
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let mut config_to_save = config.clone();
config_to_save.system_prompt = None;
config_to_save.style = None;
match serde_json::to_string_pretty(&config_to_save) {
Ok(json) => match fs::write(&path, json) {
Ok(_) => true,
Err(e) => {
error!("✖️ 保存 agent_config.json 失败: {}", e);
false
}
},
Err(e) => {
error!("✖️ 序列化 agent 配置失败: {}", e);
false
}
}
}
pub fn append_session_event(session_id: &str, event: &SessionEvent) -> bool {
let paths = SessionPaths::new(session_id);
if paths.ensure_dir().is_err() {
return false;
}
let path = paths.transcript();
let ok = match serde_json::to_string(event) {
Ok(line) => match fs::OpenOptions::new().create(true).append(true).open(&path) {
Ok(mut file) => writeln!(file, "{}", line).is_ok(),
Err(_) => false,
},
Err(_) => false,
};
if ok {
update_session_meta_on_event(session_id, event);
}
ok
}
fn update_session_meta_on_event(session_id: &str, event: &SessionEvent) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut meta = load_session_meta_file(session_id).unwrap_or_else(|| SessionMetaFile {
id: session_id.to_string(),
title: String::new(),
message_count: 0,
created_at: now,
updated_at: now,
model: None,
});
meta.updated_at = now;
match event {
SessionEvent::Msg { message: msg, .. } => {
meta.message_count += 1;
if meta.title.is_empty() && msg.role == "user" && !msg.content.is_empty() {
meta.title = msg.content.chars().take(MESSAGE_PREVIEW_MAX_LEN).collect();
}
}
SessionEvent::Clear => {
meta.message_count = 0;
}
SessionEvent::Restore { messages } => {
meta.message_count = messages.len();
if meta.title.is_empty()
&& let Some(first_user) = messages
.iter()
.find(|m| m.role == "user" && !m.content.is_empty())
{
meta.title = first_user
.content
.chars()
.take(MESSAGE_PREVIEW_MAX_LEN)
.collect();
}
}
}
let _ = save_session_meta_file(&meta);
}
pub fn find_latest_session_id() -> Option<String> {
let dir = sessions_dir();
let mut entries: Vec<(std::time::SystemTime, String)> = Vec::new();
let read_dir = match fs::read_dir(&dir) {
Ok(rd) => rd,
Err(_) => return None,
};
for entry in read_dir.flatten() {
let path = entry.path();
if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
continue;
}
let Some(id) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
let transcript = path.join("transcript.jsonl");
if let Ok(meta) = transcript.metadata()
&& let Ok(modified) = meta.modified()
{
entries.push((modified, id.to_string()));
}
}
entries.sort_by(|a, b| b.0.cmp(&a.0));
entries.into_iter().next().map(|(_, id)| id)
}
pub fn load_session(session_id: &str) -> ChatSession {
let path = SessionPaths::new(session_id).transcript();
if !path.exists() {
return ChatSession::default();
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return ChatSession::default(),
};
let mut messages: Vec<ChatMessage> = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<SessionEvent>(line) {
Ok(event) => match event {
SessionEvent::Msg { message, .. } => messages.push(message),
SessionEvent::Clear => messages.clear(),
SessionEvent::Restore { messages: restored } => messages = restored,
},
Err(_) => {
}
}
}
ChatSession { messages }
}
pub fn read_transcript_with_timestamps(path: &Path) -> Vec<(ChatMessage, u64)> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let mut out: Vec<(ChatMessage, u64)> = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(SessionEvent::Msg {
message,
timestamp_ms,
}) = serde_json::from_str::<SessionEvent>(line)
{
out.push((message, timestamp_ms));
}
}
out
}
pub fn append_event_to_path(path: &Path, event: &SessionEvent) -> bool {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let line = match serde_json::to_string(event) {
Ok(s) => s,
Err(_) => return false,
};
match fs::OpenOptions::new().create(true).append(true).open(path) {
Ok(mut file) => writeln!(file, "{}", line).is_ok(),
Err(_) => false,
}
}
pub fn load_system_prompt() -> Option<String> {
let path = system_prompt_path();
if !path.exists() {
return None;
}
match fs::read_to_string(path) {
Ok(content) => {
let trimmed = content.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
Err(e) => {
error!("✖️ 读取 system_prompt.md 失败: {}", e);
None
}
}
}
pub fn save_system_prompt(prompt: &str) -> bool {
let path = system_prompt_path();
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let trimmed = prompt.trim();
if trimmed.is_empty() {
return match fs::remove_file(&path) {
Ok(_) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
Err(e) => {
error!("✖️ 删除 system_prompt.md 失败: {}", e);
false
}
};
}
match fs::write(path, trimmed) {
Ok(_) => true,
Err(e) => {
error!("✖️ 保存 system_prompt.md 失败: {}", e);
false
}
}
}
pub fn load_style() -> Option<String> {
let path = style_path();
if !path.exists() {
return None;
}
match fs::read_to_string(path) {
Ok(content) => {
let trimmed = content.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
Err(e) => {
error!("✖️ 读取 style.md 失败: {}", e);
None
}
}
}
pub fn save_style(style: &str) -> bool {
let path = style_path();
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let trimmed = style.trim();
if trimmed.is_empty() {
return match fs::remove_file(&path) {
Ok(_) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
Err(e) => {
error!("✖️ 删除 style.md 失败: {}", e);
false
}
};
}
match fs::write(path, trimmed) {
Ok(_) => true,
Err(e) => {
error!("✖️ 保存 style.md 失败: {}", e);
false
}
}
}
pub fn load_memory() -> Option<String> {
let path = memory_path();
if !path.exists() {
return None;
}
match fs::read_to_string(path) {
Ok(content) => {
let trimmed = content.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
Err(e) => {
error!("✖️ 读取 memory.md 失败: {}", e);
None
}
}
}
pub fn load_soul() -> Option<String> {
let path = soul_path();
if !path.exists() {
return None;
}
match fs::read_to_string(path) {
Ok(content) => {
let trimmed = content.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
Err(e) => {
error!("✖️ 读取 soul.md 失败: {}", e);
None
}
}
}
pub fn save_memory(content: &str) -> bool {
let path = memory_path();
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
match fs::write(path, content) {
Ok(_) => true,
Err(e) => {
error!("✖️ 保存 memory.md 失败: {}", e);
false
}
}
}
pub fn save_soul(content: &str) -> bool {
let path = soul_path();
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
match fs::write(path, content) {
Ok(_) => true,
Err(e) => {
error!("✖️ 保存 soul.md 失败: {}", e);
false
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMetaFile {
pub id: String,
#[serde(default)]
pub title: String,
pub message_count: usize,
pub created_at: u64,
pub updated_at: u64,
#[serde(default)]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub message_count: usize,
pub first_message_preview: Option<String>,
pub updated_at: u64,
}
pub fn load_session_meta_file(session_id: &str) -> Option<SessionMetaFile> {
let path = SessionPaths::new(session_id).meta_file();
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn save_session_meta_file(meta: &SessionMetaFile) -> bool {
let paths = SessionPaths::new(&meta.id);
if paths.ensure_dir().is_err() {
return false;
}
match serde_json::to_string_pretty(meta) {
Ok(json) => fs::write(paths.meta_file(), json).is_ok(),
Err(_) => false,
}
}
fn derive_session_meta_from_transcript(session_id: &str) -> Option<SessionMetaFile> {
let paths = SessionPaths::new(session_id);
let transcript = paths.transcript();
let content = fs::read_to_string(&transcript).ok()?;
let mut message_count: usize = 0;
let mut first_user_preview: Option<String> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(event) = serde_json::from_str::<SessionEvent>(line) {
match event {
SessionEvent::Msg {
message: ref msg, ..
} => {
message_count += 1;
if first_user_preview.is_none() && msg.role == "user" && !msg.content.is_empty()
{
first_user_preview =
Some(msg.content.chars().take(MESSAGE_PREVIEW_MAX_LEN).collect());
}
}
SessionEvent::Clear => {
message_count = 0;
first_user_preview = None;
}
SessionEvent::Restore { ref messages } => {
message_count = messages.len();
first_user_preview = messages
.iter()
.find(|m| m.role == "user" && !m.content.is_empty())
.map(|m| m.content.chars().take(MESSAGE_PREVIEW_MAX_LEN).collect());
}
}
}
}
let updated_at = transcript
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
Some(SessionMetaFile {
id: session_id.to_string(),
title: first_user_preview.clone().unwrap_or_default(),
message_count,
created_at: updated_at, updated_at,
model: None,
})
}
pub fn list_sessions() -> Vec<SessionMeta> {
let dir = sessions_dir();
let read_dir = match fs::read_dir(&dir) {
Ok(rd) => rd,
Err(_) => return Vec::new(),
};
let mut ids: Vec<String> = Vec::new();
for entry in read_dir.flatten() {
let path = entry.path();
if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
continue;
}
let Some(id) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if path.join("transcript.jsonl").exists() {
ids.push(id.to_string());
}
}
let mut sessions: Vec<SessionMeta> = Vec::new();
for id in ids {
if let Some(meta_file) = load_session_meta_file(&id) {
sessions.push(SessionMeta {
id: meta_file.id,
title: if meta_file.title.is_empty() {
None
} else {
Some(meta_file.title)
},
message_count: meta_file.message_count,
first_message_preview: None,
updated_at: meta_file.updated_at,
});
continue;
}
if let Some(derived) = derive_session_meta_from_transcript(&id) {
let title = if derived.title.is_empty() {
None
} else {
Some(derived.title.clone())
};
let preview_for_ui = title.clone();
let _ = save_session_meta_file(&derived);
sessions.push(SessionMeta {
id: derived.id,
title,
message_count: derived.message_count,
first_message_preview: preview_for_ui,
updated_at: derived.updated_at,
});
}
}
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
sessions
}
pub fn generate_session_id() -> String {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_micros();
let pid = std::process::id();
format!("{:x}-{:x}", ts, pid)
}
pub fn delete_session(session_id: &str) -> bool {
let paths = SessionPaths::new(session_id);
let dir = paths.dir().to_path_buf();
if dir.exists()
&& let Err(e) = fs::remove_dir_all(&dir)
{
error!("✖️ 删除 session 目录失败: {}", e);
return false;
}
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeammateSnapshotPersist {
pub name: String,
pub role: String,
pub prompt: String,
pub worktree: bool,
pub worktree_branch: Option<String>,
pub inherit_permissions: bool,
pub status: crate::command::chat::teammate::TeammateStatusPersist,
#[serde(default)]
pub pending_user_messages: Vec<ChatMessage>,
pub tool_calls_count: usize,
pub current_tool: Option<String>,
pub work_done: bool,
}
pub fn sanitize_filename(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for c in name.chars() {
if c.is_whitespace() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') {
out.push('_');
} else {
out.push(c);
}
}
if out.chars().count() > 64 {
out = out.chars().take(64).collect();
}
if out.is_empty() {
out.push('_');
}
out
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubAgentSnapshotPersist {
pub id: String,
pub description: String,
pub mode: String,
pub status: String,
pub current_tool: Option<String>,
pub tool_calls_count: usize,
pub current_round: usize,
pub started_at_epoch: u64,
#[serde(default)]
pub transcript_file: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanStatePersist {
pub active: bool,
pub plan_file_path: Option<String>,
pub plan_content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionHookPersist {
pub event: crate::command::chat::hook::HookEvent,
pub definition: crate::command::chat::hook::HookDef,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxStatePersist {
pub extra_safe_dirs: Vec<PathBuf>,
}
fn save_session_json<T: Serialize + ?Sized>(path: &Path, data: &T) -> bool {
match serde_json::to_string_pretty(data) {
Ok(json) => fs::write(path, json).is_ok(),
Err(_) => false,
}
}
fn load_session_json<T: serde::de::DeserializeOwned>(path: &Path) -> Option<T> {
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn save_teammates_state(session_id: &str, data: &[TeammateSnapshotPersist]) -> bool {
let paths = SessionPaths::new(session_id);
let _ = paths.ensure_dir();
save_session_json(&paths.teammates_file(), data)
}
pub fn load_teammates_state(session_id: &str) -> Option<Vec<TeammateSnapshotPersist>> {
let paths = SessionPaths::new(session_id);
load_session_json(&paths.teammates_file())
}
pub fn save_subagents_state(session_id: &str, data: &[SubAgentSnapshotPersist]) -> bool {
let paths = SessionPaths::new(session_id);
let _ = paths.ensure_dir();
save_session_json(&paths.subagents_file(), data)
}
#[allow(dead_code)]
pub fn load_subagents_state(session_id: &str) -> Option<Vec<SubAgentSnapshotPersist>> {
let paths = SessionPaths::new(session_id);
load_session_json(&paths.subagents_file())
}
pub fn save_tasks_state(
session_id: &str,
data: &[crate::command::chat::tools::task::AgentTask],
) -> bool {
let paths = SessionPaths::new(session_id);
let _ = paths.ensure_dir();
save_session_json(&paths.tasks_file(), data)
}
pub fn load_tasks_state(
session_id: &str,
) -> Option<Vec<crate::command::chat::tools::task::AgentTask>> {
let paths = SessionPaths::new(session_id);
load_session_json(&paths.tasks_file())
}
pub fn save_todos_state(
session_id: &str,
data: &[crate::command::chat::tools::todo::TodoItem],
) -> bool {
let paths = SessionPaths::new(session_id);
let _ = paths.ensure_dir();
save_session_json(&paths.todos_file(), data)
}
pub fn load_todos_state(
session_id: &str,
) -> Option<Vec<crate::command::chat::tools::todo::TodoItem>> {
let paths = SessionPaths::new(session_id);
load_session_json(&paths.todos_file())
}
pub fn save_plan_state(session_id: &str, data: &PlanStatePersist) -> bool {
let paths = SessionPaths::new(session_id);
let _ = paths.ensure_dir();
save_session_json(&paths.plan_file(), data)
}
pub fn load_plan_state(session_id: &str) -> Option<PlanStatePersist> {
let paths = SessionPaths::new(session_id);
load_session_json(&paths.plan_file())
}
pub fn save_skills_state(
session_id: &str,
data: &std::collections::HashMap<String, crate::command::chat::compact::InvokedSkill>,
) -> bool {
let paths = SessionPaths::new(session_id);
let _ = paths.ensure_dir();
save_session_json(&paths.skills_file(), data)
}
pub fn load_skills_state(
session_id: &str,
) -> Option<std::collections::HashMap<String, crate::command::chat::compact::InvokedSkill>> {
let paths = SessionPaths::new(session_id);
load_session_json(&paths.skills_file())
}
pub fn save_hooks_state(session_id: &str, data: &[SessionHookPersist]) -> bool {
let paths = SessionPaths::new(session_id);
let _ = paths.ensure_dir();
save_session_json(&paths.hooks_file(), data)
}
pub fn load_hooks_state(session_id: &str) -> Option<Vec<SessionHookPersist>> {
let paths = SessionPaths::new(session_id);
load_session_json(&paths.hooks_file())
}
pub fn save_sandbox_state(session_id: &str, data: &SandboxStatePersist) -> bool {
let paths = SessionPaths::new(session_id);
let _ = paths.ensure_dir();
save_session_json(&paths.sandbox_file(), data)
}
pub fn load_sandbox_state(session_id: &str) -> Option<SandboxStatePersist> {
let paths = SessionPaths::new(session_id);
load_session_json(&paths.sandbox_file())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct TempDataDir {
root: PathBuf,
prev: Option<String>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl TempDataDir {
fn new() -> Self {
let lock = test_lock().lock().unwrap_or_else(|e| e.into_inner());
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let root = std::env::temp_dir().join(format!("jcli-storage-test-{}-{}", pid, nanos));
let _ = fs::create_dir_all(&root);
let prev = std::env::var("J_DATA_PATH").ok();
unsafe {
std::env::set_var("J_DATA_PATH", &root);
}
Self {
root,
prev,
_lock: lock,
}
}
}
impl Drop for TempDataDir {
fn drop(&mut self) {
unsafe {
match &self.prev {
Some(v) => std::env::set_var("J_DATA_PATH", v),
None => std::env::remove_var("J_DATA_PATH"),
}
}
let _ = fs::remove_dir_all(&self.root);
}
}
#[test]
fn session_paths_construction() {
let _tmp = TempDataDir::new();
let paths = SessionPaths::new("abc");
assert_eq!(paths.id(), "abc");
assert_eq!(paths.dir().file_name().unwrap(), "abc");
assert_eq!(paths.transcript().file_name().unwrap(), "transcript.jsonl");
assert_eq!(paths.meta_file().file_name().unwrap(), "session.json");
assert!(paths.transcript().parent().unwrap().ends_with("abc"));
}
#[test]
fn append_event_writes_to_new_layout() {
let _tmp = TempDataDir::new();
let paths = SessionPaths::new("append-id");
let msg = ChatMessage::text("user", "hello".to_string());
assert!(append_session_event("append-id", &SessionEvent::msg(msg)));
assert!(paths.transcript().exists());
}
#[test]
fn load_session_round_trip() {
let _tmp = TempDataDir::new();
let msg = ChatMessage::text("user", "round trip test");
assert!(append_session_event("rt-id", &SessionEvent::msg(msg)));
let session = load_session("rt-id");
assert_eq!(session.messages.len(), 1);
assert_eq!(session.messages[0].content, "round trip test");
}
#[test]
fn list_sessions_finds_sessions() {
let _tmp = TempDataDir::new();
let paths = SessionPaths::new("ls-test");
paths.ensure_dir().unwrap();
let msg = ChatMessage::text("user", "list test");
let line = serde_json::to_string(&SessionEvent::msg(msg)).unwrap();
fs::write(paths.transcript(), format!("{}\n", line)).unwrap();
let metas = list_sessions();
assert_eq!(metas.len(), 1);
assert_eq!(metas[0].id, "ls-test");
}
#[test]
fn delete_session_removes_dir() {
let _tmp = TempDataDir::new();
let paths = SessionPaths::new("del-id");
paths.ensure_dir().unwrap();
fs::write(paths.transcript(), b"").unwrap();
assert!(delete_session("del-id"));
assert!(!paths.dir().exists());
}
#[test]
fn session_meta_file_round_trip() {
let _tmp = TempDataDir::new();
let meta = SessionMetaFile {
id: "meta-test".to_string(),
title: "你好世界".to_string(),
message_count: 5,
created_at: 1000,
updated_at: 2000,
model: Some("gpt-4o".to_string()),
};
assert!(save_session_meta_file(&meta));
let loaded = load_session_meta_file("meta-test").expect("should load");
assert_eq!(loaded.id, "meta-test");
assert_eq!(loaded.title, "你好世界");
assert_eq!(loaded.message_count, 5);
assert_eq!(loaded.created_at, 1000);
assert_eq!(loaded.updated_at, 2000);
assert_eq!(loaded.model.as_deref(), Some("gpt-4o"));
}
#[test]
fn append_event_updates_meta() {
let _tmp = TempDataDir::new();
let msg1 = ChatMessage::text("user", "hello world");
assert!(append_session_event("meta-upd", &SessionEvent::msg(msg1)));
let meta = load_session_meta_file("meta-upd").expect("meta should exist");
assert_eq!(meta.id, "meta-upd");
assert_eq!(meta.message_count, 1);
assert_eq!(meta.title, "hello world");
assert!(meta.updated_at > 0);
let msg2 = ChatMessage::text("assistant", "hi there");
assert!(append_session_event("meta-upd", &SessionEvent::msg(msg2)));
let meta2 = load_session_meta_file("meta-upd").expect("meta should exist");
assert_eq!(meta2.message_count, 2);
assert_eq!(meta2.title, "hello world");
assert!(append_session_event("meta-upd", &SessionEvent::Clear));
let meta3 = load_session_meta_file("meta-upd").expect("meta should exist");
assert_eq!(meta3.message_count, 0);
}
#[test]
fn list_sessions_lazy_generates_meta() {
let _tmp = TempDataDir::new();
let paths = SessionPaths::new("lazy-gen");
paths.ensure_dir().unwrap();
let msg = ChatMessage::text("user", "lazy generation test");
let line = serde_json::to_string(&SessionEvent::msg(msg)).unwrap();
fs::write(paths.transcript(), format!("{}\n", line)).unwrap();
assert!(!paths.meta_file().exists());
let sessions = list_sessions();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "lazy-gen");
assert_eq!(sessions[0].message_count, 1);
assert_eq!(sessions[0].title.as_deref(), Some("lazy generation test"));
assert!(paths.meta_file().exists());
}
#[test]
fn session_paths_transcripts_dir() {
let _tmp = TempDataDir::new();
let paths = SessionPaths::new("tx-test");
assert!(paths.transcripts_dir().ends_with(".transcripts"));
assert_eq!(paths.transcripts_dir().parent().unwrap(), paths.dir());
}
}