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::client::{ModelType, list_models};
|
||||||
use crate::config::paths;
|
use crate::config::paths;
|
||||||
use crate::config::{AppConfig, Config, list_agents_with_descriptions, list_sessions};
|
use crate::config::{
|
||||||
use crate::utils::list_file_names;
|
AppConfig, Config, labeled_session_names, list_agents_with_descriptions, sanitize_display_text,
|
||||||
|
session_scope_dirs,
|
||||||
|
};
|
||||||
use crate::vault::Vault;
|
use crate::vault::Vault;
|
||||||
use clap_complete::{CompletionCandidate, Shell, generate};
|
use clap_complete::{CompletionCandidate, Shell, generate};
|
||||||
use clap_complete_nushell::Nushell;
|
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> {
|
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;
|
let mut i = 0;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
let arg = &args[i];
|
let arg = &args[i];
|
||||||
@@ -134,19 +140,21 @@ fn extract_agent_from_args() -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
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() {
|
fn session_candidates(cur: &str, agent: Option<&str>) -> Vec<CompletionCandidate> {
|
||||||
let sessions_dir = paths::agent_data_dir(&agent_name).join("sessions");
|
let (workspace_dir, global_dir) = session_scope_dirs(agent);
|
||||||
list_file_names(sessions_dir, ".yaml")
|
labeled_session_names(&workspace_dir, &global_dir)
|
||||||
} else {
|
|
||||||
list_sessions()
|
|
||||||
};
|
|
||||||
|
|
||||||
sessions
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|s| s.starts_with(&*cur))
|
.filter(|(name, _)| name.starts_with(cur))
|
||||||
.map(CompletionCandidate::new)
|
.map(|(name, scope)| {
|
||||||
|
CompletionCandidate::new(sanitize_display_text(&name))
|
||||||
|
.help(Some(scope.to_string().into()))
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,3 +202,112 @@ pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
|||||||
Err(_) => vec![],
|
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::{
|
pub use self::role::{
|
||||||
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_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)]
|
#[allow(unused_imports)]
|
||||||
pub use self::skill::Skill;
|
pub use self::skill::Skill;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
@@ -183,7 +183,7 @@ const MCP_FILE_NAME: &str = "mcp.json";
|
|||||||
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
|
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
|
||||||
const MEMORY_DIR_NAME: &str = "memory";
|
const MEMORY_DIR_NAME: &str = "memory";
|
||||||
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
|
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_DIR_NAME: &str = "sbx-kit";
|
||||||
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
||||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
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
|
/// 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
|
/// it; without one, rebuilds the catalog from the live models.dev + OpenRouter
|
||||||
/// APIs merged with the embedded quirks.
|
/// 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,
|
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,
|
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,
|
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::client::ProviderModels;
|
||||||
use crate::config::REPL_HISTORY_DIR_NAME;
|
use crate::config::REPL_HISTORY_DIR_NAME;
|
||||||
@@ -229,6 +229,17 @@ pub fn workspace_macros_dir() -> PathBuf {
|
|||||||
workspace_config_dir().join(MACROS_DIR_NAME)
|
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> {
|
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
|
||||||
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
|
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 {
|
pub fn agent_graph_file(agent_name: &str) -> PathBuf {
|
||||||
agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME)
|
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 {
|
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::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::{read_to_string, write};
|
use std::fmt;
|
||||||
use std::path::Path;
|
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;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
static RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{8}T\d{6}-").unwrap());
|
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
|
*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)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
#[serde(rename(serialize = "model", deserialize = "model"))]
|
#[serde(rename(serialize = "model", deserialize = "model"))]
|
||||||
@@ -120,6 +178,8 @@ pub struct Session {
|
|||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
|
scope: SessionScope,
|
||||||
|
#[serde(skip)]
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
save_session_this_time: bool,
|
save_session_this_time: bool,
|
||||||
@@ -173,6 +233,7 @@ impl Session {
|
|||||||
app: &AppConfig,
|
app: &AppConfig,
|
||||||
name: &str,
|
name: &str,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
|
scope: SessionScope,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let content = read_to_string(path)
|
let content = read_to_string(path)
|
||||||
.with_context(|| format!("Failed to load session {} at {}", name, path.display()))?;
|
.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}"))?;
|
serde_yaml::from_str(&content).with_context(|| format!("Invalid session {name}"))?;
|
||||||
|
|
||||||
session.model = Model::retrieve_model(app, &session.model_id, ModelType::Chat)?;
|
session.model = Model::retrieve_model(app, &session.model_id, ModelType::Chat)?;
|
||||||
|
session.scope = scope;
|
||||||
|
|
||||||
if let Some(autoname) = name.strip_prefix("_/") {
|
if let Some(autoname) = name.strip_prefix("_/") {
|
||||||
session.name = TEMP_SESSION_NAME.to_string();
|
session.name = TEMP_SESSION_NAME.to_string();
|
||||||
@@ -250,6 +312,14 @@ impl Session {
|
|||||||
self.name = name;
|
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) {
|
pub fn clear_autoname(&mut self) {
|
||||||
self.autoname = None;
|
self.autoname = None;
|
||||||
}
|
}
|
||||||
@@ -313,6 +383,7 @@ impl Session {
|
|||||||
pub fn export(&self) -> Result<String> {
|
pub fn export(&self) -> Result<String> {
|
||||||
let mut data = json!({
|
let mut data = json!({
|
||||||
"path": self.path,
|
"path": self.path,
|
||||||
|
"scope": self.scope.to_string(),
|
||||||
"model": self.model().id(),
|
"model": self.model().id(),
|
||||||
});
|
});
|
||||||
if let Some(temperature) = self.temperature() {
|
if let Some(temperature) = self.temperature() {
|
||||||
@@ -403,6 +474,7 @@ impl Session {
|
|||||||
if let Some(path) = &self.path {
|
if let Some(path) = &self.path {
|
||||||
items.push(("path", path.to_string()));
|
items.push(("path", path.to_string()));
|
||||||
}
|
}
|
||||||
|
items.push(("scope", self.scope.to_string()));
|
||||||
|
|
||||||
if let Some(autoname) = self.autoname() {
|
if let Some(autoname) = self.autoname() {
|
||||||
items.push(("autoname", autoname.to_string()));
|
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<()> {
|
pub fn save(&mut self, session_name: &str, session_path: &Path, is_repl: bool) -> Result<()> {
|
||||||
ensure_parent_exists(session_path)?;
|
ensure_parent_exists(session_path)?;
|
||||||
|
|
||||||
self.path = Some(session_path.display().to_string());
|
|
||||||
|
|
||||||
let content = serde_yaml::to_string(&self)
|
let content = serde_yaml::to_string(&self)
|
||||||
.with_context(|| format!("Failed to serde session '{}'", self.name))?;
|
.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!(
|
format!(
|
||||||
"Failed to write session '{}' to '{}'",
|
"Failed to write session '{}' to '{}'",
|
||||||
self.name,
|
self.name,
|
||||||
session_path.display()
|
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 {
|
if is_repl {
|
||||||
println!("✓ Saved the session to '{}'.", session_path.display());
|
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]
|
#[test]
|
||||||
fn session_deserialization_ignores_injected_role_hooks() {
|
fn session_deserialization_ignores_injected_role_hooks() {
|
||||||
// A session file is attacker-writable data: a `role_hooks:` key
|
// 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() {
|
fn session_export_includes_real_usage_only_when_reported() {
|
||||||
let empty = Session::default().export().unwrap();
|
let empty = Session::default().export().unwrap();
|
||||||
assert!(!empty.contains("real_input_tokens"));
|
assert!(!empty.contains("real_input_tokens"));
|
||||||
|
assert!(empty.contains("scope: global"));
|
||||||
|
|
||||||
let mut session = Session::default();
|
let mut session = Session::default();
|
||||||
|
session.set_scope(SessionScope::Workspace);
|
||||||
session.accumulate_token_usage(&TokenUsage {
|
session.accumulate_token_usage(&TokenUsage {
|
||||||
input_tokens: Some(5),
|
input_tokens: Some(5),
|
||||||
output_tokens: Some(7),
|
output_tokens: Some(7),
|
||||||
@@ -1365,12 +1484,97 @@ mod tests {
|
|||||||
cache_read_input_tokens: Some(3),
|
cache_read_input_tokens: Some(3),
|
||||||
});
|
});
|
||||||
let exported = session.export().unwrap();
|
let exported = session.export().unwrap();
|
||||||
|
assert!(exported.contains("scope: workspace"));
|
||||||
assert!(exported.contains("real_input_tokens: 5"));
|
assert!(exported.contains("real_input_tokens: 5"));
|
||||||
assert!(exported.contains("real_output_tokens: 7"));
|
assert!(exported.contains("real_output_tokens: 7"));
|
||||||
assert!(exported.contains("cache_read_tokens: 3"));
|
assert!(exported.contains("cache_read_tokens: 3"));
|
||||||
assert!(!exported.contains("cache_creation_tokens"));
|
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]
|
#[test]
|
||||||
fn session_todo_state_survives_yaml_round_trip() {
|
fn session_todo_state_survives_yaml_round_trip() {
|
||||||
let mut session = Session::default();
|
let mut session = Session::default();
|
||||||
|
|||||||
+212
-5
@@ -13,7 +13,8 @@ use crate::client::{
|
|||||||
};
|
};
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
AgentVariables, AppConfig, AssertState, Input, LastMessage, MacroState, RequestContext,
|
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::config::{AssetCategory, paths};
|
||||||
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
||||||
@@ -1088,11 +1089,24 @@ pub async fn run_repl_command(
|
|||||||
Some(("role", name)) => {
|
Some(("role", name)) => {
|
||||||
ctx.save_role(name)?;
|
ctx.save_role(name)?;
|
||||||
}
|
}
|
||||||
Some(("session", name)) => {
|
Some(("session", rest)) => match parse_repl_save_session(rest) {
|
||||||
ctx.save_session(name)?;
|
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" => {
|
".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> {
|
pub fn builtin_command_names() -> Vec<&'static str> {
|
||||||
let mut names: Vec<&'static str> = REPL_COMMANDS
|
let mut names: Vec<&'static str> = REPL_COMMANDS
|
||||||
.iter()
|
.iter()
|
||||||
@@ -3207,6 +3276,144 @@ mod tests {
|
|||||||
assert_eq!(parse_repl_uninstall(None), ReplUninstallDispatch::Usage);
|
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]
|
#[test]
|
||||||
fn repl_install_and_uninstall_help_text_cover_the_full_surface() {
|
fn repl_install_and_uninstall_help_text_cover_the_full_surface() {
|
||||||
let install = repl_install_help();
|
let install = repl_install_help();
|
||||||
|
|||||||
+28
-5
@@ -1,7 +1,10 @@
|
|||||||
//! Test-only helpers shared across modules' unit tests.
|
//! 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 log::{Level, LevelFilter, Log, Metadata, Record};
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::{Mutex, Once, OnceLock};
|
use std::sync::{Mutex, Once, OnceLock};
|
||||||
|
|
||||||
struct TestLogCollector;
|
struct TestLogCollector;
|
||||||
@@ -147,12 +150,19 @@ impl Drop for EnvVarGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Points the config dir at a fresh temp directory for the guard's lifetime
|
/// Points the config dir and the workspace config dir at fresh temp
|
||||||
/// and removes it on drop, including on panic. Tests using it must serialize
|
/// directories for the guard's lifetime and removes them on drop, including
|
||||||
/// (`#[serial]`) — the config-dir env var is process-global.
|
/// 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 {
|
pub(crate) struct TestConfigDirGuard {
|
||||||
_env: EnvVarGuard,
|
_env: EnvVarGuard,
|
||||||
pub(crate) path: std::path::PathBuf,
|
_workspace_env: EnvVarGuard,
|
||||||
|
_sessions_env: EnvVarGuard,
|
||||||
|
pub(crate) path: PathBuf,
|
||||||
|
workspace_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestConfigDirGuard {
|
impl TestConfigDirGuard {
|
||||||
@@ -163,9 +173,19 @@ impl TestConfigDirGuard {
|
|||||||
.as_nanos();
|
.as_nanos();
|
||||||
let path = std::env::temp_dir().join(format!("coyote-{label}-{unique}"));
|
let path = std::env::temp_dir().join(format!("coyote-{label}-{unique}"));
|
||||||
std::fs::create_dir_all(&path).unwrap();
|
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 {
|
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,
|
path,
|
||||||
|
workspace_path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,5 +193,8 @@ impl TestConfigDirGuard {
|
|||||||
impl Drop for TestConfigDirGuard {
|
impl Drop for TestConfigDirGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = std::fs::remove_dir_all(&self.path);
|
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
|
//! a real ~/.coyote_password or a sandbox shell — cannot mask
|
||||||
//! virgin-environment regressions.
|
//! virgin-environment regressions.
|
||||||
|
|
||||||
use std::env;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use std::{env, process};
|
||||||
|
|
||||||
fn fresh_config_dir(label: &str) -> PathBuf {
|
fn fresh_config_dir(label: &str) -> PathBuf {
|
||||||
let unique = SystemTime::now()
|
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"
|
"--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