feat: execute non-isolated macros on the live REPL context

Make the macro isolated field live (design §3, §9 step 5):
isolated: false now runs the interpolated steps via run_repl_command
on the live RequestContext — session-recorded, conversation-visible,
with mutating steps persisting by design — while isolated: true keeps
the forked execution byte-for-byte unchanged.

- Add macro_non_isolated companion field beside macro_flag; both
  fork-propagation sites mirror it verbatim.
- RAII MacroModeGuard wraps the whole &mut RequestContext (DerefMut
  passthrough) and restores flag+mode on every exit path, including a
  failing step; steps remain fail-fast.
- Reject nested macro invocation when the current mode is non-isolated
  ("nested macros not allowed in non-isolated mode"); an isolated
  macro's step may still run a non-isolated macro inline on its fork.
- use_agent now suppresses the agent's default session only for
  isolated macros; a non-isolated .agent step engages it as if typed.
This commit is contained in:
2026-08-21 11:55:13 -06:00
parent e94bd450cd
commit 5478c5a239
2 changed files with 435 additions and 2 deletions
+348 -1
View File
@@ -2,12 +2,13 @@ use crate::config::paths;
use crate::config::{RequestContext, RoleLike, ensure_parent_exists}; use crate::config::{RequestContext, RoleLike, ensure_parent_exists};
use crate::repl::{run_repl_command, split_args_text}; use crate::repl::{run_repl_command, split_args_text};
use crate::utils::{AbortSignal, multiline_text}; use crate::utils::{AbortSignal, multiline_text};
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow, bail};
use indexmap::IndexMap; use indexmap::IndexMap;
use rust_embed::Embed; use rust_embed::Embed;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs::{File, read_to_string}; use std::fs::{File, read_to_string};
use std::io::Write; use std::io::Write;
use std::ops::{Deref, DerefMut};
use std::sync::Arc; use std::sync::Arc;
#[derive(Embed)] #[derive(Embed)]
@@ -21,6 +22,9 @@ pub async fn macro_execute(
args: Option<&str>, args: Option<&str>,
abort_signal: AbortSignal, abort_signal: AbortSignal,
) -> Result<()> { ) -> Result<()> {
if ctx.in_non_isolated_macro() {
bail!("nested macros not allowed in non-isolated mode");
}
let macro_value = Macro::load(name)?; let macro_value = Macro::load(name)?;
let (mut new_args, text) = split_args_text(args.unwrap_or_default(), cfg!(windows)); let (mut new_args, text) = split_args_text(args.unwrap_or_default(), cfg!(windows));
if !text.is_empty() { if !text.is_empty() {
@@ -29,6 +33,17 @@ pub async fn macro_execute(
let variables = macro_value let variables = macro_value
.resolve_variables(&new_args) .resolve_variables(&new_args)
.map_err(|err| anyhow!("{err}. Usage: {}", macro_value.usage(name)))?; .map_err(|err| anyhow!("{err}. Usage: {}", macro_value.usage(name)))?;
if !macro_value.isolated {
let mut live = MacroModeGuard::new(ctx);
for step in &macro_value.steps {
let command = Macro::interpolate_command(step, &variables);
println!(">> {}", multiline_text(&command));
run_repl_command(&mut live, abort_signal.clone(), &command).await?;
}
return Ok(());
}
let role = ctx.extract_role(ctx.app.config.as_ref())?; let role = ctx.extract_role(ctx.app.config.as_ref())?;
let mut app_config = (*ctx.app.config).clone(); let mut app_config = (*ctx.app.config).clone();
app_config.temperature = role.temperature(); app_config.temperature = role.temperature();
@@ -69,6 +84,50 @@ pub async fn macro_execute(
Ok(()) Ok(())
} }
/// Marks a live context as executing a non-isolated macro for the duration of
/// its steps. Restores the previous flag and mode on drop, so every exit path
/// — including a failing step — leaves the context as it found it.
struct MacroModeGuard<'a> {
ctx: &'a mut RequestContext,
prev_flag: bool,
prev_non_isolated: bool,
}
impl<'a> MacroModeGuard<'a> {
fn new(ctx: &'a mut RequestContext) -> Self {
let prev_flag = ctx.macro_flag;
let prev_non_isolated = ctx.macro_non_isolated;
ctx.macro_flag = true;
ctx.macro_non_isolated = true;
Self {
ctx,
prev_flag,
prev_non_isolated,
}
}
}
impl Deref for MacroModeGuard<'_> {
type Target = RequestContext;
fn deref(&self) -> &Self::Target {
self.ctx
}
}
impl DerefMut for MacroModeGuard<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.ctx
}
}
impl Drop for MacroModeGuard<'_> {
fn drop(&mut self) {
self.ctx.macro_flag = self.prev_flag;
self.ctx.macro_non_isolated = self.prev_non_isolated;
}
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Macro { pub struct Macro {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -187,6 +246,90 @@ fn default_true() -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{AppState, Session, WorkingMode};
use crate::utils::{create_abort_signal, get_env_name};
use serial_test::serial;
use std::env;
use std::fs::{create_dir_all, remove_dir_all, write};
use std::future::Future;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
struct TestConfigDirGuard {
key: String,
previous: Option<std::ffi::OsString>,
path: PathBuf,
}
impl TestConfigDirGuard {
fn new() -> Self {
let key = get_env_name("config_dir");
let previous = env::var_os(&key);
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = env::temp_dir().join(format!("coyote-macros-tests-{unique}"));
create_dir_all(&path).unwrap();
unsafe {
env::set_var(&key, &path);
}
Self {
key,
previous,
path,
}
}
}
impl Drop for TestConfigDirGuard {
fn drop(&mut self) {
if let Some(previous) = &self.previous {
unsafe {
env::set_var(&self.key, previous);
}
} else {
unsafe {
env::remove_var(&self.key);
}
}
let _ = remove_dir_all(&self.path);
}
}
fn test_ctx() -> RequestContext {
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
}
fn write_macro_file(name: &str, content: &str) {
let path = paths::macros_dir().join(format!("{name}.yaml"));
ensure_parent_exists(&path).unwrap();
write(&path, content).unwrap();
}
/// Drives a macro-execution future to completion on a thread with extra
/// stack headroom: nested `run_repl_command` poll frames are deep in
/// debug builds and overflow the 2 MiB default test-thread stack.
fn run_async<F>(f: F) -> F::Output
where
F: Future + Send,
F::Output: Send,
{
std::thread::scope(|scope| {
std::thread::Builder::new()
.stack_size(8 * 1024 * 1024)
.spawn_scoped(scope, || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(f)
})
.unwrap()
.join()
.unwrap()
})
}
fn var(name: &str, rest: bool, default: Option<&str>) -> MacroVariable { fn var(name: &str, rest: bool, default: Option<&str>) -> MacroVariable {
MacroVariable { MacroVariable {
@@ -458,4 +601,208 @@ variables:
assert!(!m.steps.is_empty(), "asset '{}'", file.as_ref()); assert!(!m.steps.is_empty(), "asset '{}'", file.as_ref());
} }
} }
#[test]
#[serial]
fn non_isolated_steps_run_on_live_ctx_and_mutations_persist() {
let _guard = TestConfigDirGuard::new();
write_macro_file(
"live-macro",
"isolated: false\nsteps:\n - \".set temperature 0.42\"\n",
);
let mut ctx = test_ctx();
ctx.session = Some(Session::default());
run_async(macro_execute(
&mut ctx,
"live-macro",
None,
create_abort_signal(),
))
.unwrap();
assert!(ctx.session.is_some(), "live session must survive the macro");
assert_eq!(
ctx.session.as_ref().unwrap().temperature(),
Some(0.42),
"the step must mutate the live context's session, not a fork"
);
assert!(!ctx.macro_flag, "flag must be restored after success");
assert!(
!ctx.macro_non_isolated,
"mode must be restored after success"
);
}
#[test]
#[serial]
fn non_isolated_step_failure_aborts_and_restores_flag_and_mode() {
let _guard = TestConfigDirGuard::new();
write_macro_file(
"fail-macro",
"isolated: false\nsteps:\n - \".set temperature 0.9\"\n - \".update\"\n - \".set temperature 0.1\"\n",
);
let mut ctx = test_ctx();
ctx.session = Some(Session::default());
let result = run_async(macro_execute(
&mut ctx,
"fail-macro",
None,
create_abort_signal(),
));
assert!(result.is_err(), "a failing step must abort the macro");
assert_eq!(
ctx.session.as_ref().unwrap().temperature(),
Some(0.9),
"completed steps' mutations persist; steps after the failure never run"
);
assert!(!ctx.macro_flag, "flag must be restored on the error path");
assert!(
!ctx.macro_non_isolated,
"mode must be restored on the error path"
);
}
#[test]
fn nested_macro_rejected_when_non_isolated_mode_active() {
let mut ctx = test_ctx();
ctx.macro_flag = true;
ctx.macro_non_isolated = true;
let result = run_async(macro_execute(
&mut ctx,
"anything",
None,
create_abort_signal(),
));
let err = result.unwrap_err().to_string();
assert!(
err.contains("nested macros not allowed in non-isolated mode"),
"{err}"
);
}
#[test]
#[serial]
fn non_isolated_macro_step_invoking_macro_is_rejected() {
let _guard = TestConfigDirGuard::new();
write_macro_file(
"outer-macro",
"isolated: false\nsteps:\n - \".inner-macro\"\n",
);
write_macro_file(
"inner-macro",
"isolated: false\nsteps:\n - \".set temperature 0.5\"\n",
);
let mut ctx = test_ctx();
ctx.session = Some(Session::default());
let result = run_async(macro_execute(
&mut ctx,
"outer-macro",
None,
create_abort_signal(),
));
let err = result.unwrap_err().to_string();
assert!(
err.contains("nested macros not allowed in non-isolated mode"),
"{err}"
);
assert_eq!(
ctx.session.as_ref().unwrap().temperature(),
None,
"the nested macro's steps must not run"
);
assert!(!ctx.macro_flag);
assert!(!ctx.macro_non_isolated);
}
#[test]
#[serial]
fn isolated_macro_step_runs_non_isolated_macro_inline_on_fork() {
let _guard = TestConfigDirGuard::new();
write_macro_file("iso-outer-macro", "steps:\n - \".inner-macro\"\n");
write_macro_file(
"inner-macro",
"isolated: false\nsteps:\n - \".set temperature 0.33\"\n",
);
let mut ctx = test_ctx();
ctx.session = Some(Session::default());
run_async(macro_execute(
&mut ctx,
"iso-outer-macro",
None,
create_abort_signal(),
))
.unwrap();
assert_eq!(
ctx.session.as_ref().unwrap().temperature(),
None,
"the inline run happens on the fork, never on the live context"
);
assert!(!ctx.macro_flag);
assert!(!ctx.macro_non_isolated);
}
#[test]
#[serial]
fn isolated_macro_still_forks_and_leaves_live_ctx_untouched() {
let _guard = TestConfigDirGuard::new();
write_macro_file("iso-macro", "steps:\n - \".set temperature 0.77\"\n");
let mut ctx = test_ctx();
ctx.session = Some(Session::default());
let app_before = Arc::clone(&ctx.app.config);
run_async(macro_execute(
&mut ctx,
"iso-macro",
None,
create_abort_signal(),
))
.unwrap();
assert!(ctx.session.is_some());
assert_eq!(
ctx.session.as_ref().unwrap().temperature(),
None,
"an isolated macro's mutations must stay on the fork"
);
assert!(
Arc::ptr_eq(&ctx.app.config, &app_before),
"isolated execution must not swap the live app config"
);
assert!(!ctx.macro_flag);
}
#[test]
#[serial]
fn guard_restores_prior_flag_values_after_inline_run() {
let _guard = TestConfigDirGuard::new();
write_macro_file(
"inner-macro",
"isolated: false\nsteps:\n - \".set temperature 0.11\"\n",
);
let mut ctx = test_ctx();
ctx.macro_flag = true;
run_async(macro_execute(
&mut ctx,
"inner-macro",
None,
create_abort_signal(),
))
.unwrap();
assert!(
ctx.macro_flag,
"a pre-existing flag must be restored, not cleared"
);
assert!(!ctx.macro_non_isolated);
}
} }
+87 -1
View File
@@ -233,6 +233,10 @@ pub struct RequestContext {
pub app: Arc<AppState>, pub app: Arc<AppState>,
pub macro_flag: bool, pub macro_flag: bool,
/// Companion to `macro_flag`: true while a non-isolated macro is running
/// its steps directly on this live context. Isolated macros execute on a
/// forked context and leave this false.
pub macro_non_isolated: bool,
pub info_flag: bool, pub info_flag: bool,
pub working_mode: WorkingMode, pub working_mode: WorkingMode,
@@ -270,6 +274,7 @@ impl RequestContext {
Self { Self {
app, app,
macro_flag: false, macro_flag: false,
macro_non_isolated: false,
info_flag: false, info_flag: false,
working_mode, working_mode,
model: Default::default(), model: Default::default(),
@@ -324,6 +329,7 @@ impl RequestContext {
Ok(Self { Ok(Self {
app, app,
macro_flag: false, macro_flag: false,
macro_non_isolated: false,
info_flag, info_flag,
working_mode, working_mode,
model, model,
@@ -373,6 +379,7 @@ impl RequestContext {
Self { Self {
app: Arc::clone(&self.app), app: Arc::clone(&self.app),
macro_flag: self.macro_flag, macro_flag: self.macro_flag,
macro_non_isolated: self.macro_non_isolated,
info_flag: self.info_flag, info_flag: self.info_flag,
working_mode: self.working_mode, working_mode: self.working_mode,
model: self.model.clone(), model: self.model.clone(),
@@ -412,6 +419,7 @@ impl RequestContext {
Self { Self {
app, app,
macro_flag: parent.macro_flag, macro_flag: parent.macro_flag,
macro_non_isolated: parent.macro_non_isolated,
info_flag: parent.info_flag, info_flag: parent.info_flag,
working_mode: WorkingMode::Cmd, working_mode: WorkingMode::Cmd,
model: parent.model.clone(), model: parent.model.clone(),
@@ -2480,6 +2488,14 @@ impl RequestContext {
Ok(()) Ok(())
} }
/// Whether a non-isolated macro is currently running its steps on this
/// context. Macro invocations are rejected in this mode: the nested
/// macro's steps would interleave with the outer macro's on the live
/// session.
pub fn in_non_isolated_macro(&self) -> bool {
self.macro_flag && self.macro_non_isolated
}
/// The resolved macro set for the active context (workspace + global /// The resolved macro set for the active context (workspace + global
/// discovery, effective `enabled_macros` allowlist, built-in shadowing). /// discovery, effective `enabled_macros` allowlist, built-in shadowing).
pub fn macro_policy(&self) -> MacroPolicy { pub fn macro_policy(&self) -> MacroPolicy {
@@ -4016,8 +4032,11 @@ impl RequestContext {
// Graph agents manage their own state; never engage a session, // Graph agents manage their own state; never engage a session,
// not even an inherited app-level `agent_session` default. // not even an inherited app-level `agent_session` default.
// Isolated macros suppress an inherited default too — their forked
// context has no session to return to. A non-isolated macro's `.agent`
// step engages it exactly as if the user had typed the command.
let session_name = session_name.map(|v| v.to_string()).or_else(|| { let session_name = session_name.map(|v| v.to_string()).or_else(|| {
if self.macro_flag || is_graph_agent { if (self.macro_flag && !self.macro_non_isolated) || is_graph_agent {
None None
} else { } else {
agent.agent_session().map(|v| v.to_string()) agent.agent_session().map(|v| v.to_string())
@@ -6566,6 +6585,73 @@ mod tests {
); );
} }
#[test]
#[serial]
fn use_agent_suppresses_inherited_session_in_isolated_macro() {
let _guard = TestConfigDirGuard::new();
let mut ctx = create_test_ctx();
ctx.macro_flag = true;
ctx.update_app_config(|app| app.agent_session = Some("inherited".to_string()));
let app = ctx.app.config.clone();
let agent_name = format!(
"test_agent_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let agent_dir = paths::agent_data_dir(&agent_name);
create_dir_all(&agent_dir).unwrap();
write(
agent_dir.join("config.yaml"),
format!("name: {agent_name}\ninstructions: hi\n"),
)
.unwrap();
let abort = utils::create_abort_signal();
run_async(ctx.use_agent(&app, &agent_name, None, abort)).unwrap();
assert!(
ctx.session.is_none(),
"an isolated macro must keep suppressing the agent's default session"
);
}
#[test]
#[serial]
fn use_agent_engages_inherited_session_in_non_isolated_macro() {
let _guard = TestConfigDirGuard::new();
let mut ctx = create_test_ctx();
ctx.macro_flag = true;
ctx.macro_non_isolated = true;
ctx.update_app_config(|app| app.agent_session = Some("inherited".to_string()));
let app = ctx.app.config.clone();
let agent_name = format!(
"test_agent_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let agent_dir = paths::agent_data_dir(&agent_name);
create_dir_all(&agent_dir).unwrap();
write(
agent_dir.join("config.yaml"),
format!("name: {agent_name}\ninstructions: hi\n"),
)
.unwrap();
let abort = utils::create_abort_signal();
run_async(ctx.use_agent(&app, &agent_name, None, abort)).unwrap();
assert!(
ctx.session.is_some(),
"a non-isolated macro's agent step must engage the default session as if typed"
);
}
fn first_file(dir: &Path) -> Option<PathBuf> { fn first_file(dir: &Path) -> Option<PathBuf> {
for entry in read_dir(dir).ok()?.flatten() { for entry in read_dir(dir).ok()?.flatten() {
let path = entry.path(); let path = entry.path();