feat: support workspace-level sessions as well as global sessions
This commit is contained in:
8 files changed
+1965
-112
No files matched your search
+131
-14
@@ -1,7 +1,9 @@
|
||||
use crate::client::{ModelType, list_models};
|
||||
use crate::config::paths;
|
||||
use crate::config::{AppConfig, Config, list_agents_with_descriptions, list_sessions};
|
||||
use crate::utils::list_file_names;
|
||||
use crate::config::{
|
||||
AppConfig, Config, labeled_session_names, list_agents_with_descriptions, sanitize_display_text,
|
||||
session_scope_dirs,
|
||||
};
|
||||
use crate::vault::Vault;
|
||||
use clap_complete::{CompletionCandidate, Shell, generate};
|
||||
use clap_complete_nushell::Nushell;
|
||||
@@ -115,7 +117,11 @@ pub(super) fn bundle_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
}
|
||||
|
||||
fn extract_agent_from_args() -> Option<String> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
extract_agent_from_args_in(env::args())
|
||||
}
|
||||
|
||||
fn extract_agent_from_args_in<I: IntoIterator<Item = String>>(args: I) -> Option<String> {
|
||||
let args: Vec<String> = args.into_iter().collect();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = &args[i];
|
||||
@@ -134,19 +140,21 @@ fn extract_agent_from_args() -> Option<String> {
|
||||
}
|
||||
|
||||
pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
let cur = current.to_string_lossy();
|
||||
session_candidates(
|
||||
¤t.to_string_lossy(),
|
||||
extract_agent_from_args().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
let sessions = if let Some(agent_name) = extract_agent_from_args() {
|
||||
let sessions_dir = paths::agent_data_dir(&agent_name).join("sessions");
|
||||
list_file_names(sessions_dir, ".yaml")
|
||||
} else {
|
||||
list_sessions()
|
||||
};
|
||||
|
||||
sessions
|
||||
fn session_candidates(cur: &str, agent: Option<&str>) -> Vec<CompletionCandidate> {
|
||||
let (workspace_dir, global_dir) = session_scope_dirs(agent);
|
||||
labeled_session_names(&workspace_dir, &global_dir)
|
||||
.into_iter()
|
||||
.filter(|s| s.starts_with(&*cur))
|
||||
.map(CompletionCandidate::new)
|
||||
.filter(|(name, _)| name.starts_with(cur))
|
||||
.map(|(name, scope)| {
|
||||
CompletionCandidate::new(sanitize_display_text(&name))
|
||||
.help(Some(scope.to_string().into()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -194,3 +202,112 @@ pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::default_sessions_dir;
|
||||
use crate::testing::{EnvVarGuard, TestConfigDirGuard};
|
||||
use serial_test::serial;
|
||||
use std::path::Path;
|
||||
|
||||
fn seed(dir: &Path, name: &str) {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
fs::write(dir.join(format!("{name}.yaml")), "model: x\n").unwrap();
|
||||
}
|
||||
|
||||
fn labeled(candidates: Vec<CompletionCandidate>) -> Vec<(String, Option<String>)> {
|
||||
candidates
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
(
|
||||
c.get_value().to_string_lossy().into_owned(),
|
||||
c.get_help().map(|h| h.to_string()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn session_completer_labels_scope_and_prefers_workspace() {
|
||||
let _guard = TestConfigDirGuard::new("cli-session-completer");
|
||||
seed(&paths::workspace_sessions_dir(), "both");
|
||||
seed(&default_sessions_dir(), "both");
|
||||
seed(&default_sessions_dir(), "only-global");
|
||||
seed(&paths::workspace_sessions_dir(), "only-ws");
|
||||
|
||||
assert_eq!(
|
||||
labeled(session_completer(OsStr::new(""))),
|
||||
vec![
|
||||
("both".to_string(), Some("workspace".to_string())),
|
||||
("only-ws".to_string(), Some("workspace".to_string())),
|
||||
("only-global".to_string(), Some("global".to_string())),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
labeled(session_completer(OsStr::new("on"))),
|
||||
vec![
|
||||
("only-ws".to_string(), Some("workspace".to_string())),
|
||||
("only-global".to_string(), Some("global".to_string())),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn session_candidates_use_the_agent_scope_dirs() {
|
||||
let _guard = TestConfigDirGuard::new("cli-agent-session-completer");
|
||||
let _agent_data_dir = EnvVarGuard::unset("A_DATA_DIR");
|
||||
seed(&paths::workspace_agent_sessions_dir("a"), "both");
|
||||
seed(&paths::agent_sessions_dir("a"), "both");
|
||||
seed(&paths::agent_sessions_dir("a"), "agent-global");
|
||||
seed(&default_sessions_dir(), "plain-global");
|
||||
|
||||
assert_eq!(
|
||||
labeled(session_candidates("", Some("a"))),
|
||||
vec![
|
||||
("both".to_string(), Some("workspace".to_string())),
|
||||
("agent-global".to_string(), Some("global".to_string())),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
labeled(session_candidates("", None)),
|
||||
vec![("plain-global".to_string(), Some("global".to_string()))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_agent_from_args_in_reads_both_flag_forms() {
|
||||
fn args(s: &str) -> Vec<String> {
|
||||
s.split_whitespace().map(str::to_string).collect()
|
||||
}
|
||||
assert_eq!(
|
||||
extract_agent_from_args_in(args("coyote --agent a --session x")),
|
||||
Some("a".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_agent_from_args_in(args("coyote -a b")),
|
||||
Some("b".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_agent_from_args_in(args("coyote --agent=c")),
|
||||
Some("c".to_string())
|
||||
);
|
||||
assert_eq!(extract_agent_from_args_in(args("coyote --agent")), None);
|
||||
assert_eq!(extract_agent_from_args_in(args("coyote --session x")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn config_dir_guard_clears_and_restores_the_sessions_dir_override() {
|
||||
let key = crate::utils::get_env_name("sessions_dir");
|
||||
let junk = std::path::PathBuf::from("/nonexistent/junk-sessions");
|
||||
let _outer = EnvVarGuard::set(&key, &junk);
|
||||
{
|
||||
let guard = TestConfigDirGuard::new("cli-sessions-dir-guard");
|
||||
assert!(default_sessions_dir().starts_with(&guard.path));
|
||||
}
|
||||
assert_eq!(default_sessions_dir(), junk);
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -58,7 +58,7 @@ pub use self::request_context::{
|
||||
pub use self::role::{
|
||||
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
|
||||
};
|
||||
pub use self::session::Session;
|
||||
pub use self::session::{Session, SessionScope, labeled_session_names, session_scope_dirs};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::skill::Skill;
|
||||
#[allow(unused_imports)]
|
||||
@@ -183,7 +183,7 @@ const MCP_FILE_NAME: &str = "mcp.json";
|
||||
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
|
||||
const MEMORY_DIR_NAME: &str = "memory";
|
||||
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
|
||||
const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
|
||||
pub(crate) const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
|
||||
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
|
||||
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
||||
@@ -583,10 +583,6 @@ pub fn default_sessions_dir() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_sessions() -> Vec<String> {
|
||||
list_file_names(default_sessions_dir(), ".yaml")
|
||||
}
|
||||
|
||||
/// Refreshes the models override. With a URL, mirrors a full models.yaml from
|
||||
/// it; without one, rebuilds the catalog from the live models.dev + OpenRouter
|
||||
/// APIs merged with the embedded quirks.
|
||||
|
||||
+31
-1
@@ -5,7 +5,7 @@ use super::{
|
||||
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, HOOKS_DIR_NAME, INSTALLED_BUNDLES_FILE_NAME,
|
||||
MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride,
|
||||
RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME,
|
||||
SBX_MIXIN_KITS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
|
||||
SBX_MIXIN_KITS_DIR_NAME, SESSIONS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
|
||||
};
|
||||
use crate::client::ProviderModels;
|
||||
use crate::config::REPL_HISTORY_DIR_NAME;
|
||||
@@ -229,6 +229,17 @@ pub fn workspace_macros_dir() -> PathBuf {
|
||||
workspace_config_dir().join(MACROS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn workspace_sessions_dir() -> PathBuf {
|
||||
workspace_config_dir().join(SESSIONS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn workspace_agent_sessions_dir(name: &str) -> PathBuf {
|
||||
workspace_config_dir()
|
||||
.join(AGENTS_DIR_NAME)
|
||||
.join(name)
|
||||
.join(SESSIONS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
|
||||
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
|
||||
}
|
||||
@@ -332,6 +343,10 @@ pub fn agent_data_dir(name: &str) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn agent_sessions_dir(name: &str) -> PathBuf {
|
||||
agent_data_dir(name).join(SESSIONS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn agent_graph_file(agent_name: &str) -> PathBuf {
|
||||
agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME)
|
||||
}
|
||||
@@ -854,6 +869,21 @@ mod tests {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn workspace_session_dirs_live_under_workspace_config_dir() {
|
||||
with_workspace_dir(|_, ws_dir| {
|
||||
assert_eq!(workspace_sessions_dir(), ws_dir.join(SESSIONS_DIR_NAME));
|
||||
assert_eq!(
|
||||
workspace_agent_sessions_dir("helper"),
|
||||
ws_dir
|
||||
.join(AGENTS_DIR_NAME)
|
||||
.join("helper")
|
||||
.join(SESSIONS_DIR_NAME)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mod user_mcp_resolution {
|
||||
|
||||
+1243
-73
File diff suppressed because it is too large.
Load diff
+210
-6
@@ -12,8 +12,10 @@ use inquire::{Confirm, Text, validator::Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{read_to_string, write};
|
||||
use std::path::Path;
|
||||
use std::fmt;
|
||||
use std::fs::{OpenOptions, read_to_string, remove_file, rename};
|
||||
use std::io::{self, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{8}T\d{6}-").unwrap());
|
||||
@@ -29,6 +31,62 @@ fn cost_is_zero(v: &f64) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SessionScope {
|
||||
#[default]
|
||||
Global,
|
||||
Workspace,
|
||||
}
|
||||
|
||||
impl fmt::Display for SessionScope {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SessionScope::Global => write!(f, "global"),
|
||||
SessionScope::Workspace => write!(f, "workspace"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_scope_dirs(agent: Option<&str>) -> (PathBuf, PathBuf) {
|
||||
match agent {
|
||||
Some(agent) => (
|
||||
paths::workspace_agent_sessions_dir(agent),
|
||||
paths::agent_sessions_dir(agent),
|
||||
),
|
||||
None => (paths::workspace_sessions_dir(), default_sessions_dir()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Session names across both scope dirs, workspace first, deduped by name so
|
||||
/// a workspace session shadows a global one with the same name.
|
||||
pub fn labeled_session_names(
|
||||
workspace_dir: &Path,
|
||||
global_dir: &Path,
|
||||
) -> Vec<(String, SessionScope)> {
|
||||
let mut values = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for (dir, scope) in [
|
||||
(workspace_dir, SessionScope::Workspace),
|
||||
(global_dir, SessionScope::Global),
|
||||
] {
|
||||
for name in list_file_names(dir, ".yaml") {
|
||||
if seen.insert(name.clone()) {
|
||||
values.push((name, scope));
|
||||
}
|
||||
}
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
pub fn merged_session_names(workspace_dir: &Path, global_dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = labeled_session_names(workspace_dir, global_dir)
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
names
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct Session {
|
||||
#[serde(rename(serialize = "model", deserialize = "model"))]
|
||||
@@ -120,6 +178,8 @@ pub struct Session {
|
||||
#[serde(skip)]
|
||||
path: Option<String>,
|
||||
#[serde(skip)]
|
||||
scope: SessionScope,
|
||||
#[serde(skip)]
|
||||
dirty: bool,
|
||||
#[serde(skip)]
|
||||
save_session_this_time: bool,
|
||||
@@ -173,6 +233,7 @@ impl Session {
|
||||
app: &AppConfig,
|
||||
name: &str,
|
||||
path: &Path,
|
||||
scope: SessionScope,
|
||||
) -> Result<Self> {
|
||||
let content = read_to_string(path)
|
||||
.with_context(|| format!("Failed to load session {} at {}", name, path.display()))?;
|
||||
@@ -180,6 +241,7 @@ impl Session {
|
||||
serde_yaml::from_str(&content).with_context(|| format!("Invalid session {name}"))?;
|
||||
|
||||
session.model = Model::retrieve_model(app, &session.model_id, ModelType::Chat)?;
|
||||
session.scope = scope;
|
||||
|
||||
if let Some(autoname) = name.strip_prefix("_/") {
|
||||
session.name = TEMP_SESSION_NAME.to_string();
|
||||
@@ -250,6 +312,14 @@ impl Session {
|
||||
self.name = name;
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> SessionScope {
|
||||
self.scope
|
||||
}
|
||||
|
||||
pub fn set_scope(&mut self, scope: SessionScope) {
|
||||
self.scope = scope;
|
||||
}
|
||||
|
||||
pub fn clear_autoname(&mut self) {
|
||||
self.autoname = None;
|
||||
}
|
||||
@@ -313,6 +383,7 @@ impl Session {
|
||||
pub fn export(&self) -> Result<String> {
|
||||
let mut data = json!({
|
||||
"path": self.path,
|
||||
"scope": self.scope.to_string(),
|
||||
"model": self.model().id(),
|
||||
});
|
||||
if let Some(temperature) = self.temperature() {
|
||||
@@ -403,6 +474,7 @@ impl Session {
|
||||
if let Some(path) = &self.path {
|
||||
items.push(("path", path.to_string()));
|
||||
}
|
||||
items.push(("scope", self.scope.to_string()));
|
||||
|
||||
if let Some(autoname) = self.autoname() {
|
||||
items.push(("autoname", autoname.to_string()));
|
||||
@@ -886,17 +958,52 @@ impl Session {
|
||||
pub fn save(&mut self, session_name: &str, session_path: &Path, is_repl: bool) -> Result<()> {
|
||||
ensure_parent_exists(session_path)?;
|
||||
|
||||
self.path = Some(session_path.display().to_string());
|
||||
|
||||
let content = serde_yaml::to_string(&self)
|
||||
.with_context(|| format!("Failed to serde session '{}'", self.name))?;
|
||||
write(session_path, content).with_context(|| {
|
||||
// Write through a sibling temp file and rename it over the target so a
|
||||
// symlink planted at the session path is replaced, never followed. The
|
||||
// temp file is opened with create_new so a symlink planted at the temp
|
||||
// path is not followed either; a stale entry there is removed and the
|
||||
// open retried once.
|
||||
let mut tmp_name = session_path.as_os_str().to_os_string();
|
||||
tmp_name.push(format!(".{}.tmp", process::id()));
|
||||
let tmp_path = PathBuf::from(tmp_name);
|
||||
let write_context = || {
|
||||
format!(
|
||||
"Failed to write session '{}' to '{}'",
|
||||
self.name,
|
||||
session_path.display()
|
||||
)
|
||||
})?;
|
||||
};
|
||||
let open_tmp = || {
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&tmp_path)
|
||||
};
|
||||
let mut tmp_file = match open_tmp() {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||
remove_file(&tmp_path).with_context(write_context)?;
|
||||
open_tmp().with_context(write_context)?
|
||||
}
|
||||
Err(error) => return Err(error).with_context(write_context),
|
||||
};
|
||||
if let Err(error) = tmp_file
|
||||
.write_all(content.as_bytes())
|
||||
.and_then(|_| tmp_file.flush())
|
||||
{
|
||||
drop(tmp_file);
|
||||
let _ = remove_file(&tmp_path);
|
||||
return Err(error).with_context(write_context);
|
||||
}
|
||||
drop(tmp_file);
|
||||
if let Err(error) = rename(&tmp_path, session_path) {
|
||||
let _ = remove_file(&tmp_path);
|
||||
return Err(error).with_context(write_context);
|
||||
}
|
||||
|
||||
self.path = Some(session_path.display().to_string());
|
||||
|
||||
if is_repl {
|
||||
println!("✓ Saved the session to '{}'.", session_path.display());
|
||||
@@ -1317,6 +1424,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_scope_is_not_serialized() {
|
||||
let mut session = Session::default();
|
||||
session.set_scope(SessionScope::Workspace);
|
||||
|
||||
let yaml = serde_yaml::to_string(&session).unwrap();
|
||||
|
||||
assert!(!yaml.contains("scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_deserialization_ignores_injected_role_hooks() {
|
||||
// A session file is attacker-writable data: a `role_hooks:` key
|
||||
@@ -1356,8 +1473,10 @@ mod tests {
|
||||
fn session_export_includes_real_usage_only_when_reported() {
|
||||
let empty = Session::default().export().unwrap();
|
||||
assert!(!empty.contains("real_input_tokens"));
|
||||
assert!(empty.contains("scope: global"));
|
||||
|
||||
let mut session = Session::default();
|
||||
session.set_scope(SessionScope::Workspace);
|
||||
session.accumulate_token_usage(&TokenUsage {
|
||||
input_tokens: Some(5),
|
||||
output_tokens: Some(7),
|
||||
@@ -1365,12 +1484,97 @@ mod tests {
|
||||
cache_read_input_tokens: Some(3),
|
||||
});
|
||||
let exported = session.export().unwrap();
|
||||
assert!(exported.contains("scope: workspace"));
|
||||
assert!(exported.contains("real_input_tokens: 5"));
|
||||
assert!(exported.contains("real_output_tokens: 7"));
|
||||
assert!(exported.contains("cache_read_tokens: 3"));
|
||||
assert!(!exported.contains("cache_creation_tokens"));
|
||||
}
|
||||
|
||||
fn rendered_lines(session: &Session) -> Vec<String> {
|
||||
let options = crate::render::RenderOptions {
|
||||
raw_markdown: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut render = MarkdownRender::init(options).unwrap();
|
||||
session
|
||||
.render(&mut render, &None)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_render_reports_scope_line() {
|
||||
let mut session = Session::default();
|
||||
assert!(
|
||||
rendered_lines(&session)
|
||||
.iter()
|
||||
.any(|line| line.starts_with("scope") && line.ends_with("global"))
|
||||
);
|
||||
|
||||
session.set_scope(SessionScope::Workspace);
|
||||
assert!(
|
||||
rendered_lines(&session)
|
||||
.iter()
|
||||
.any(|line| line.starts_with("scope") && line.ends_with("workspace"))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn session_save_replaces_a_symlink_instead_of_following_it() {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("coyote-session-save-symlink-{unique}"));
|
||||
let sessions_dir = dir.join("sessions");
|
||||
std::fs::create_dir_all(&sessions_dir).unwrap();
|
||||
let target = dir.join("target.txt");
|
||||
std::fs::write(&target, "untouched").unwrap();
|
||||
let link = sessions_dir.join("link.yaml");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
|
||||
let mut session = Session::default();
|
||||
session.save("link", &link, false).unwrap();
|
||||
|
||||
assert_eq!(read_to_string(&target).unwrap(), "untouched");
|
||||
assert!(link.symlink_metadata().unwrap().is_file());
|
||||
assert!(read_to_string(&link).unwrap().contains("model:"));
|
||||
assert_eq!(session.name(), "link");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn session_save_does_not_follow_a_symlink_planted_at_the_temp_path() {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("coyote-session-save-tmp-symlink-{unique}"));
|
||||
let sessions_dir = dir.join("sessions");
|
||||
std::fs::create_dir_all(&sessions_dir).unwrap();
|
||||
let target = dir.join("target.txt");
|
||||
std::fs::write(&target, "untouched").unwrap();
|
||||
let destination = sessions_dir.join("notes.yaml");
|
||||
let mut tmp_name = destination.as_os_str().to_os_string();
|
||||
tmp_name.push(format!(".{}.tmp", process::id()));
|
||||
let tmp_path = PathBuf::from(tmp_name);
|
||||
std::os::unix::fs::symlink(&target, &tmp_path).unwrap();
|
||||
|
||||
let mut session = Session::default();
|
||||
session.save("notes", &destination, false).unwrap();
|
||||
|
||||
assert_eq!(read_to_string(&target).unwrap(), "untouched");
|
||||
assert!(destination.symlink_metadata().unwrap().is_file());
|
||||
assert!(read_to_string(&destination).unwrap().contains("model:"));
|
||||
assert!(!tmp_path.exists(), "temp file must not be left behind");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_todo_state_survives_yaml_round_trip() {
|
||||
let mut session = Session::default();
|
||||
|
||||
+212
-5
@@ -13,7 +13,8 @@ use crate::client::{
|
||||
};
|
||||
use crate::config::{
|
||||
AgentVariables, AppConfig, AssertState, Input, LastMessage, MacroState, RequestContext,
|
||||
StateFlags, flatten_prompt_messages, macro_execute, resolve_prompt_args, sanitize_display_text,
|
||||
SessionScope, StateFlags, flatten_prompt_messages, macro_execute, resolve_prompt_args,
|
||||
sanitize_display_text,
|
||||
};
|
||||
use crate::config::{AssetCategory, paths};
|
||||
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
||||
@@ -1088,11 +1089,24 @@ pub async fn run_repl_command(
|
||||
Some(("role", name)) => {
|
||||
ctx.save_role(name)?;
|
||||
}
|
||||
Some(("session", name)) => {
|
||||
ctx.save_session(name)?;
|
||||
}
|
||||
Some(("session", rest)) => match parse_repl_save_session(rest) {
|
||||
ReplSaveSessionDispatch::Run { name, scope } => {
|
||||
ctx.save_session(name.as_deref(), scope)?;
|
||||
}
|
||||
ReplSaveSessionDispatch::Help => println!("{}", repl_save_session_help()),
|
||||
ReplSaveSessionDispatch::Conflict => {
|
||||
println!("--workspace and --global are mutually exclusive")
|
||||
}
|
||||
ReplSaveSessionDispatch::Usage => {
|
||||
println!(
|
||||
"Usage: .save session [name] [--workspace|--global] (see `.save session --help`)"
|
||||
)
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
println!(r#"Usage: .save <role|session> [name]"#)
|
||||
println!(
|
||||
r#"Usage: .save role [name] | .save session [name] [--workspace|--global]"#
|
||||
)
|
||||
}
|
||||
},
|
||||
".edit" => {
|
||||
@@ -1934,6 +1948,61 @@ fn parse_repl_uninstall(args: Option<&str>) -> ReplUninstallDispatch {
|
||||
}
|
||||
}
|
||||
|
||||
fn repl_save_session_help() -> String {
|
||||
r#"Save the current session to a file.
|
||||
|
||||
Usage:
|
||||
.save session [name] [--workspace|--global]
|
||||
|
||||
Flags:
|
||||
--workspace Save under .coyote/ in the current workspace
|
||||
--global Save under the global config dir
|
||||
|
||||
Without a flag the session is re-saved wherever it currently lives; a brand-new
|
||||
session lives in the global scope. Re-homing a session never deletes the old file."#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum ReplSaveSessionDispatch {
|
||||
Run {
|
||||
name: Option<String>,
|
||||
scope: Option<SessionScope>,
|
||||
},
|
||||
Help,
|
||||
Conflict,
|
||||
Usage,
|
||||
}
|
||||
|
||||
fn parse_repl_save_session(args: Option<&str>) -> ReplSaveSessionDispatch {
|
||||
let tokens: Vec<&str> = args.unwrap_or("").split_whitespace().collect();
|
||||
if tokens
|
||||
.iter()
|
||||
.any(|token| *token == "--help" || *token == "-h")
|
||||
{
|
||||
return ReplSaveSessionDispatch::Help;
|
||||
}
|
||||
let mut scope = None;
|
||||
let mut names = Vec::new();
|
||||
for token in tokens {
|
||||
let requested = match token {
|
||||
"--workspace" => SessionScope::Workspace,
|
||||
"--global" => SessionScope::Global,
|
||||
other if other.starts_with('-') => return ReplSaveSessionDispatch::Usage,
|
||||
other => {
|
||||
names.push(other);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if scope.is_some_and(|current| current != requested) {
|
||||
return ReplSaveSessionDispatch::Conflict;
|
||||
}
|
||||
scope = Some(requested);
|
||||
}
|
||||
let name = (!names.is_empty()).then(|| names.join(" "));
|
||||
ReplSaveSessionDispatch::Run { name, scope }
|
||||
}
|
||||
|
||||
pub fn builtin_command_names() -> Vec<&'static str> {
|
||||
let mut names: Vec<&'static str> = REPL_COMMANDS
|
||||
.iter()
|
||||
@@ -3207,6 +3276,144 @@ mod tests {
|
||||
assert_eq!(parse_repl_uninstall(None), ReplUninstallDispatch::Usage);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_repl_save_session_routes_name_and_scope_in_either_order() {
|
||||
assert_eq!(
|
||||
parse_repl_save_session(None),
|
||||
ReplSaveSessionDispatch::Run {
|
||||
name: None,
|
||||
scope: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("notes")),
|
||||
ReplSaveSessionDispatch::Run {
|
||||
name: Some("notes".to_string()),
|
||||
scope: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("notes --workspace")),
|
||||
ReplSaveSessionDispatch::Run {
|
||||
name: Some("notes".to_string()),
|
||||
scope: Some(SessionScope::Workspace)
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("--global notes")),
|
||||
ReplSaveSessionDispatch::Run {
|
||||
name: Some("notes".to_string()),
|
||||
scope: Some(SessionScope::Global)
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("--workspace")),
|
||||
ReplSaveSessionDispatch::Run {
|
||||
name: None,
|
||||
scope: Some(SessionScope::Workspace)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_repl_save_session_rejects_conflicts_and_unknown_flags_but_joins_spaced_names() {
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("--workspace --global")),
|
||||
ReplSaveSessionDispatch::Conflict
|
||||
);
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("notes --force")),
|
||||
ReplSaveSessionDispatch::Usage
|
||||
);
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("one two")),
|
||||
ReplSaveSessionDispatch::Run {
|
||||
name: Some("one two".to_string()),
|
||||
scope: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("--help")),
|
||||
ReplSaveSessionDispatch::Help
|
||||
);
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("notes -h")),
|
||||
ReplSaveSessionDispatch::Help
|
||||
);
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("--workspace --global -h")),
|
||||
ReplSaveSessionDispatch::Help
|
||||
);
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("--global notes --global")),
|
||||
ReplSaveSessionDispatch::Run {
|
||||
name: Some("notes".to_string()),
|
||||
scope: Some(SessionScope::Global)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_save_session_help_text_covers_the_full_surface() {
|
||||
let help = repl_save_session_help();
|
||||
for needle in [
|
||||
"--workspace",
|
||||
"--global",
|
||||
"[name]",
|
||||
"never deletes the old file",
|
||||
] {
|
||||
assert!(help.contains(needle), "save session help missing {needle}");
|
||||
}
|
||||
}
|
||||
|
||||
// Usage probe (spec-first, criterion 5): `-h/--help` wins from ANY
|
||||
// position — including when it is preceded by a token that would
|
||||
// otherwise route to Usage (unknown `-` flag) or Conflict (both scope
|
||||
// flags) — while, absent help, an unknown `-` token still routes to
|
||||
// Usage even when a valid scope flag is present, and a repeated
|
||||
// identical flag stays idempotent for `--workspace` too.
|
||||
#[test]
|
||||
fn usage_probe_save_session_help_prescan_beats_usage_and_conflict() {
|
||||
for input in [
|
||||
"--bogus -h",
|
||||
"-h --bogus",
|
||||
"notes --bogus --help",
|
||||
"--workspace --global notes -h",
|
||||
"--help --workspace --global",
|
||||
"one -h two",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some(input)),
|
||||
ReplSaveSessionDispatch::Help,
|
||||
"help must win for {input:?}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("--workspace --bogus notes")),
|
||||
ReplSaveSessionDispatch::Usage
|
||||
);
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("-x")),
|
||||
ReplSaveSessionDispatch::Usage
|
||||
);
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("--workspace notes --workspace")),
|
||||
ReplSaveSessionDispatch::Run {
|
||||
name: Some("notes".to_string()),
|
||||
scope: Some(SessionScope::Workspace)
|
||||
}
|
||||
);
|
||||
// Non-flag tokens join into ONE name even when a flag sits between them.
|
||||
assert_eq!(
|
||||
parse_repl_save_session(Some("one --global two")),
|
||||
ReplSaveSessionDispatch::Run {
|
||||
name: Some("one two".to_string()),
|
||||
scope: Some(SessionScope::Global)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_install_and_uninstall_help_text_cover_the_full_surface() {
|
||||
let install = repl_install_help();
|
||||
|
||||
+28
-5
@@ -1,7 +1,10 @@
|
||||
//! Test-only helpers shared across modules' unit tests.
|
||||
|
||||
use crate::config::WORKSPACE_COYOTE_DIR_NAME;
|
||||
use crate::utils;
|
||||
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, Once, OnceLock};
|
||||
|
||||
struct TestLogCollector;
|
||||
@@ -147,12 +150,19 @@ impl Drop for EnvVarGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Points the config dir at a fresh temp directory for the guard's lifetime
|
||||
/// and removes it on drop, including on panic. Tests using it must serialize
|
||||
/// (`#[serial]`) — the config-dir env var is process-global.
|
||||
/// Points the config dir and the workspace config dir at fresh temp
|
||||
/// directories for the guard's lifetime and removes them on drop, including
|
||||
/// on panic, so the developer's real `./.coyote/` never leaks into a test.
|
||||
/// The sessions-dir override is cleared too, so Global-scope session writes
|
||||
/// land under the temp config dir rather than the developer's real one.
|
||||
/// Tests using it must serialize (`#[serial]`) — the env vars are
|
||||
/// process-global.
|
||||
pub(crate) struct TestConfigDirGuard {
|
||||
_env: EnvVarGuard,
|
||||
pub(crate) path: std::path::PathBuf,
|
||||
_workspace_env: EnvVarGuard,
|
||||
_sessions_env: EnvVarGuard,
|
||||
pub(crate) path: PathBuf,
|
||||
workspace_path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestConfigDirGuard {
|
||||
@@ -163,9 +173,19 @@ impl TestConfigDirGuard {
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("coyote-{label}-{unique}"));
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
let workspace_path = std::env::temp_dir()
|
||||
.join(format!("coyote-{label}-ws-{unique}"))
|
||||
.join(WORKSPACE_COYOTE_DIR_NAME);
|
||||
std::fs::create_dir_all(&workspace_path).unwrap();
|
||||
Self {
|
||||
_env: EnvVarGuard::set(crate::utils::get_env_name("config_dir"), &path),
|
||||
_env: EnvVarGuard::set(utils::get_env_name("config_dir"), &path),
|
||||
_workspace_env: EnvVarGuard::set(
|
||||
utils::get_env_name("workspace_config_dir"),
|
||||
&workspace_path,
|
||||
),
|
||||
_sessions_env: EnvVarGuard::unset(utils::get_env_name("sessions_dir")),
|
||||
path,
|
||||
workspace_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,5 +193,8 @@ impl TestConfigDirGuard {
|
||||
impl Drop for TestConfigDirGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
if let Some(root) = self.workspace_path.parent() {
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
-2
@@ -15,11 +15,11 @@
|
||||
//! a real ~/.coyote_password or a sandbox shell — cannot mask
|
||||
//! virgin-environment regressions.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::{env, process};
|
||||
|
||||
fn fresh_config_dir(label: &str) -> PathBuf {
|
||||
let unique = SystemTime::now()
|
||||
@@ -438,3 +438,109 @@ fn sync_models_stays_on_bootstrap_path() {
|
||||
"--sync-models: expected the builtins bootstrap to populate the config dir"
|
||||
);
|
||||
}
|
||||
|
||||
fn run_list_sessions(
|
||||
cwd: &Path,
|
||||
config_dir: &Path,
|
||||
global_sessions: &Path,
|
||||
home_dir: &Path,
|
||||
) -> process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_coyote"))
|
||||
.arg("--list-sessions")
|
||||
.env("COYOTE_CONFIG_DIR", config_dir)
|
||||
.env("COYOTE_SESSIONS_DIR", global_sessions)
|
||||
.env("HOME", home_dir)
|
||||
.env("USERPROFILE", home_dir)
|
||||
.env_remove("IS_SANDBOX")
|
||||
.env_remove("COYOTE_PROVIDER")
|
||||
.env_remove("COYOTE_PLATFORM")
|
||||
.env_remove("COYOTE_WORKSPACE_CONFIG_DIR")
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn seed_session(dir: &Path, name: &str) {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
fs::write(dir.join(format!("{name}.yaml")), "messages: []\n").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_probe_list_sessions_merges_cwd_workspace_scope_with_global() {
|
||||
let config_dir = fresh_config_dir("list-sessions-ws");
|
||||
let home_dir = fresh_config_dir("list-sessions-ws-home");
|
||||
let global_sessions = fresh_config_dir("list-sessions-ws-global");
|
||||
let workspace = fresh_config_dir("list-sessions-ws-cwd");
|
||||
let bare_cwd = fresh_config_dir("list-sessions-ws-bare");
|
||||
let workspace_sessions = workspace.join(".coyote").join("sessions");
|
||||
|
||||
seed_session(&global_sessions, "both");
|
||||
seed_session(&global_sessions, "glob-only");
|
||||
seed_session(&workspace_sessions, "both");
|
||||
seed_session(&workspace_sessions, "ws-only");
|
||||
|
||||
let in_workspace = run_list_sessions(&workspace, &config_dir, &global_sessions, &home_dir);
|
||||
let in_bare = run_list_sessions(&bare_cwd, &config_dir, &global_sessions, &home_dir);
|
||||
|
||||
let config_leftover: Vec<_> = fs::read_dir(&config_dir)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect();
|
||||
let bare_created_workspace = bare_cwd.join(".coyote").exists();
|
||||
let workspace_entries: Vec<_> = fs::read_dir(&workspace_sessions)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
for dir in [
|
||||
&config_dir,
|
||||
&home_dir,
|
||||
&global_sessions,
|
||||
&workspace,
|
||||
&bare_cwd,
|
||||
] {
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
for (label, output) in [("workspace", &in_workspace), ("bare", &in_bare)] {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"--list-sessions ({label}): expected exit 0, got {:?}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let lines = |output: &process::Output| -> Vec<String> {
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
lines(&in_workspace),
|
||||
vec!["both", "glob-only", "ws-only"],
|
||||
"workspace CWD: merged, deduplicated, sorted"
|
||||
);
|
||||
assert_eq!(
|
||||
lines(&in_bare),
|
||||
vec!["both", "glob-only"],
|
||||
"CWD without .coyote/: behaviour unchanged (global listing only)"
|
||||
);
|
||||
assert!(
|
||||
!bare_created_workspace,
|
||||
"--list-sessions must never create .coyote/ in the CWD"
|
||||
);
|
||||
assert!(
|
||||
config_leftover.is_empty(),
|
||||
"--list-sessions must write nothing into the config dir: {config_leftover:?}"
|
||||
);
|
||||
let mut workspace_entries = workspace_entries;
|
||||
workspace_entries.sort_unstable();
|
||||
assert_eq!(
|
||||
workspace_entries,
|
||||
vec!["both.yaml", "ws-only.yaml"],
|
||||
"listing is read-only for the workspace sessions dir"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user