use std::sync::Arc;
use super::Agent;
use crate::channel::Channel;
pub(crate) fn insert_main_agent_ctx(
env: &mut std::collections::HashMap<String, String>,
conv_id: Option<&str>,
) {
env.insert("ZEPH_AGENT_TYPE".to_owned(), "main".to_owned());
if let Some(id) = conv_id {
env.insert("ZEPH_AGENT_ID".to_owned(), id.to_owned());
}
}
impl<C: Channel> Agent<C> {
pub(super) fn mcp_dispatch(&self) -> Option<McpManagerDispatch> {
self.services
.mcp
.manager
.as_ref()
.map(|m| McpManagerDispatch(Arc::clone(m)))
}
#[tracing::instrument(name = "core.agent.check_cwd_changed", skip_all, level = "debug")]
pub(crate) async fn check_cwd_changed(&mut self) {
let current = match std::env::current_dir() {
Ok(p) => p,
Err(e) => {
tracing::warn!("check_cwd_changed: failed to get cwd: {e}");
return;
}
};
if current == self.runtime.lifecycle.last_known_cwd {
return;
}
let old_cwd =
std::mem::replace(&mut self.runtime.lifecycle.last_known_cwd, current.clone());
self.services.session.env_context.working_dir = current.display().to_string();
tracing::info!(
old = %old_cwd.display(),
new = %current.display(),
"working directory changed"
);
self.channel
.send_status_best_effort("Working directory changed\u{2026}")
.await;
let hooks = self.services.session.hooks_config.cwd_changed.clone();
if hooks.is_empty() {
tracing::debug!("CwdChanged: no hooks configured, skipping");
} else {
tracing::debug!(count = hooks.len(), "CwdChanged: firing hooks");
let mut env = std::collections::HashMap::new();
env.insert("ZEPH_OLD_CWD".to_owned(), old_cwd.display().to_string());
env.insert("ZEPH_NEW_CWD".to_owned(), current.display().to_string());
let conv_id_str = self
.services
.memory
.persistence
.conversation_id
.map(|id| id.0.to_string());
insert_main_agent_ctx(&mut env, conv_id_str.as_deref());
let dispatch = self.mcp_dispatch();
let mcp: Option<&dyn zeph_subagent::McpDispatch> = dispatch
.as_ref()
.map(|d| d as &dyn zeph_subagent::McpDispatch);
if let Err(e) = zeph_subagent::hooks::fire_hooks(&hooks, &env, mcp, None).await {
tracing::warn!(error = %e, "CwdChanged hook failed");
} else {
tracing::info!(count = hooks.len(), "CwdChanged: hooks fired");
}
}
self.channel
.send_status_best_effort("Re-scoping repo map\u{2026}")
.await;
self.services.index.cached_repo_map = None;
if self.runtime.config.safe_mode {
tracing::debug!("safe mode active: skipping instruction re-discovery after cwd change");
} else if self.runtime.instructions.reload_state.is_some() {
self.channel
.send_status_best_effort("Re-discovering project instructions\u{2026}")
.await;
if let Some(ref mut state) = self.runtime.instructions.reload_state {
state.base_dir.clone_from(¤t);
}
self.reload_instructions().await;
}
self.channel.send_status_best_effort("").await;
}
#[tracing::instrument(name = "core.agent.handle_file_changed", skip_all, level = "debug")]
pub(crate) async fn handle_file_changed(
&mut self,
event: crate::file_watcher::FileChangedEvent,
) {
tracing::info!(path = %event.path.display(), "file changed");
self.channel
.send_status_best_effort("Running file-change hook\u{2026}")
.await;
let hooks = self
.services
.session
.hooks_config
.file_changed_hooks
.clone();
if hooks.is_empty() {
tracing::debug!(path = %event.path.display(), "FileChanged: no hooks configured, skipping");
} else {
tracing::debug!(count = hooks.len(), path = %event.path.display(), "FileChanged: firing hooks");
let mut env = std::collections::HashMap::new();
env.insert(
"ZEPH_CHANGED_PATH".to_owned(),
event.path.display().to_string(),
);
let conv_id_str = self
.services
.memory
.persistence
.conversation_id
.map(|id| id.0.to_string());
insert_main_agent_ctx(&mut env, conv_id_str.as_deref());
let dispatch = self.mcp_dispatch();
let mcp: Option<&dyn zeph_subagent::McpDispatch> = dispatch
.as_ref()
.map(|d| d as &dyn zeph_subagent::McpDispatch);
if let Err(e) = zeph_subagent::hooks::fire_hooks(&hooks, &env, mcp, None).await {
tracing::warn!(error = %e, "FileChanged hook failed");
} else {
tracing::info!(count = hooks.len(), path = %event.path.display(), "FileChanged: hooks fired");
}
}
self.channel.send_status_best_effort("").await;
}
}
pub(super) struct McpManagerDispatch(Arc<zeph_mcp::McpManager>);
impl zeph_subagent::McpDispatch for McpManagerDispatch {
fn call_tool<'a>(
&'a self,
server: &'a str,
tool: &'a str,
args: serde_json::Value,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<serde_json::Value, String>> + Send + 'a>,
> {
Box::pin(async move {
self.0
.call_tool(server, tool, args)
.await
.map(|result| {
let blocks: Vec<serde_json::Value> = result
.content
.iter()
.map(|c| serde_json::Value::String(zeph_mcp::render_content_block(c)))
.collect();
serde_json::Value::Array(blocks)
})
.map_err(|e| e.to_string())
})
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::insert_main_agent_ctx;
#[allow(clippy::wildcard_imports)]
use crate::agent::agent_tests::*;
fn simulate_cwd_change(
agent: &mut Agent<MockChannel>,
from: &std::path::Path,
to: &std::path::Path,
) {
agent.runtime.lifecycle.last_known_cwd = from.to_path_buf();
std::env::set_current_dir(to).unwrap();
}
#[tokio::test]
async fn check_cwd_changed_invalidates_repo_map_cache() {
let original_cwd = std::env::current_dir().unwrap();
let harness = QuickTestAgent::minimal("ok");
let mut agent = harness.agent;
agent.services.index.cached_repo_map = Some((
"<repo_map>stale</repo_map>".to_owned(),
std::time::Instant::now(),
));
let from = tempfile::tempdir().unwrap();
let to = tempfile::tempdir().unwrap();
simulate_cwd_change(&mut agent, from.path(), to.path());
agent.check_cwd_changed().await;
assert!(
agent.services.index.cached_repo_map.is_none(),
"cached_repo_map must be cleared after a cwd change"
);
let _ = std::env::set_current_dir(&original_cwd);
}
#[tokio::test]
async fn check_cwd_changed_skips_instruction_redisovery_when_safe_mode_active() {
let original_cwd = std::env::current_dir().unwrap();
let harness = QuickTestAgent::minimal("ok");
let mut agent = harness.agent;
agent.runtime.config.safe_mode = true;
let from = tempfile::tempdir().unwrap();
let to = tempfile::tempdir().unwrap();
std::fs::write(to.path().join("zeph.md"), "# target-dir instructions").unwrap();
agent.runtime.instructions.reload_state =
Some(crate::instructions::InstructionReloadState {
base_dir: from.path().to_path_buf(),
provider_kinds: vec![crate::config::ProviderKind::Claude],
explicit_files: Vec::new(),
auto_detect: false,
});
let sentinel_blocks = vec![crate::instructions::InstructionBlock {
source: from.path().join("zeph.md"),
content: "# original instructions (must survive)".to_owned(),
}];
agent.runtime.instructions.blocks = sentinel_blocks.clone();
simulate_cwd_change(&mut agent, from.path(), to.path());
agent.check_cwd_changed().await;
assert_eq!(
agent.runtime.instructions.blocks.len(),
sentinel_blocks.len(),
"instruction blocks must be unchanged when safe_mode is active"
);
assert_eq!(
agent.runtime.instructions.blocks[0].content, sentinel_blocks[0].content,
"safe-mode session must not pick up the new directory's zeph.md"
);
let _ = std::env::set_current_dir(&original_cwd);
}
#[tokio::test]
async fn check_cwd_changed_rediscovers_instructions_when_not_safe_mode() {
let original_cwd = std::env::current_dir().unwrap();
let harness = QuickTestAgent::minimal("ok");
let mut agent = harness.agent;
assert!(!agent.runtime.config.safe_mode);
let from = tempfile::tempdir().unwrap();
let to = tempfile::tempdir().unwrap();
std::fs::write(to.path().join("zeph.md"), "# target-dir instructions").unwrap();
agent.runtime.instructions.reload_state =
Some(crate::instructions::InstructionReloadState {
base_dir: from.path().to_path_buf(),
provider_kinds: vec![crate::config::ProviderKind::Claude],
explicit_files: Vec::new(),
auto_detect: false,
});
agent.runtime.instructions.blocks = Vec::new();
simulate_cwd_change(&mut agent, from.path(), to.path());
agent.check_cwd_changed().await;
assert_eq!(
agent.runtime.instructions.blocks.len(),
1,
"instructions must be re-discovered from the new cwd"
);
assert!(
agent.runtime.instructions.blocks[0]
.content
.contains("target-dir instructions"),
"re-discovered block must come from the new directory's zeph.md"
);
let _ = std::env::set_current_dir(&original_cwd);
}
#[test]
fn insert_main_agent_ctx_always_sets_agent_type() {
let mut env = HashMap::new();
insert_main_agent_ctx(&mut env, None);
assert_eq!(env.get("ZEPH_AGENT_TYPE").map(String::as_str), Some("main"));
}
#[test]
fn insert_main_agent_ctx_sets_agent_id_when_some() {
let mut env = HashMap::new();
insert_main_agent_ctx(&mut env, Some("conv-abc"));
assert_eq!(
env.get("ZEPH_AGENT_ID").map(String::as_str),
Some("conv-abc")
);
}
#[test]
fn insert_main_agent_ctx_omits_agent_id_when_none() {
let mut env = HashMap::new();
insert_main_agent_ctx(&mut env, None);
assert!(
!env.contains_key("ZEPH_AGENT_ID"),
"ZEPH_AGENT_ID must be absent when conv_id is None"
);
}
}