feat(swarm): /swarm mode — a standing instruction to fan work out

The `agent_swarm` tool works with the mode off; what the mode adds is the
doctrine — decompose finely, give every member a disjoint scope, do not
do the work yourself. `/swarm`, `/swarm on|off`, `/swarm <task>`, gated
on the tool actually being in the toolset.

Two triggers, not upstream's three. Upstream's `tool` trigger exists so
invoking the tool makes the doctrine appear; kigi's tool carries its own
description and works with the mode off, so that trigger would add a
state only the tool can reach. `Manual` persists until switched off,
`Task` expires at turn end — both are real user intents.

Load-bearing decisions, each from a defect found in review:

- Expiry is a DROP guard, not a post-loop call. A user interrupt aborts
  the turn future rather than cancelling it, so post-loop code never
  runs; three `?` paths skip it too. Either way a `/swarm <task>` mode
  would leak into the next, unrelated prompt.
- `enter` is a total no-op while armed, so the `/swarm <task>` shorthand
  cannot downgrade a standing `/swarm on` into a per-turn mode that then
  disarms itself. This matches upstream; the first cut diverged, and the
  test asserting the divergence was inverted with the fix.
- An explicit `/swarm off` retracts UNCONDITIONALLY. The doctrine rides
  the conversation and so survives a resume, a fork and a compaction that
  an in-memory flag does not; trusting the flag left a session reading
  "off" with the instruction still steering and no way to clear it. That
  also retires the `reminder_live` field, whose doc comment claimed to
  know something the code cannot.
- The mode is deliberately NOT persisted, unlike /goal and /graph: those
  strand real work when lost, this is a prompt hint whose recovery is
  retyping one command. Recorded in AGENTS.md so the omission reads as a
  decision, not a gap.
- The pre-session gate is `subagents_enabled`, not a hardcoded `true`:
  the builder strips `agent_swarm` wherever it strips `task`, and
  advertising it then offers a menu entry that resolves to literal text.
- The exit reminder is as emphatic as the doctrine it revokes; a single
  weak clause is the likelier of the two to be summarised away.

The doctrine rides the existing `push_system_reminder` channel rather
than a second injection path of its own.
This commit is contained in:
2026-07-27 02:39:55 -04:00
parent 9edb8729ef
commit 16328e55f9
20 changed files with 474 additions and 4 deletions
@@ -357,6 +357,11 @@ impl MvpAgent {
// time, so advertise pre-session; the in-session path
// re-checks the live toolset.
graph: goal && self.cfg.borrow().resolve_graph().value,
// Tool-dependent, so fail closed like every other tool gate: the
// builder strips `agent_swarm` whenever subagents are unavailable,
// and advertising it then offers a menu entry that resolves to
// literal prompt text.
swarm: self.cfg.borrow().subagents_enabled,
..crate::session::slash_commands::CommandAvailability::default()
}
}
@@ -121,6 +121,8 @@ mod model_switch;
mod prompt_queue;
#[path = "acp_session_impl/slash_exec.rs"]
mod slash_exec;
#[path = "acp_session_impl/swarm.rs"]
mod swarm;
use super::PromptOrigin;
use super::acp_types;
use super::chat_persistence;
@@ -616,6 +618,9 @@ pub(crate) struct SessionActor {
/// layered over the goal engine. Modeled after `goal_tracker` above;
/// all graph state logic lives in `graph_tracker.rs`.
pub(crate) graph_tracker: Arc<parking_lot::Mutex<crate::session::graph_tracker::GraphTracker>>,
/// Swarm mode: a standing instruction to fan work out, independent of the
/// goal/graph engines — it steers tool choice, it does not drive turns.
pub(crate) swarm_mode: std::cell::Cell<crate::session::swarm_mode::SwarmMode>,
/// Max graph nodes running concurrently (1 = serial G0 behavior).
/// Cached at actor construction from `resolve_graph_concurrency`.
pub(crate) graph_concurrency: u32,
@@ -1033,6 +1038,9 @@ impl SessionActor {
// Graph rides the goal harness: nodes execute as goals, so
// `/graph` is only real when `/goal` is.
graph: self.graph_enabled && goal,
swarm: tool_names
.iter()
.any(|n| n == kigi_tools::implementations::kigi::AGENT_SWARM_TOOL_NAME),
}
}
/// Names of every tool registered with the session's tool bridge.
@@ -709,6 +709,36 @@ impl SessionActor {
BuiltinAction::GoalSet { .. } => {
unreachable!("GoalSet is intercepted in handle_prompt")
}
BuiltinAction::SwarmSet { enabled } => {
let msg = self.apply_swarm_mode(
enabled.then_some(crate::session::swarm_mode::SwarmTrigger::Manual),
);
self.send_slash_command_output(&msg).await;
ok_end_turn(0, None)
}
BuiltinAction::SwarmToggle => {
let turning_on = !self.swarm_mode.get().is_active();
let msg = self.apply_swarm_mode(
turning_on.then_some(crate::session::swarm_mode::SwarmTrigger::Manual),
);
self.send_slash_command_output(&msg).await;
ok_end_turn(0, None)
}
// `/swarm <task>` is handled before dispatch (it has to seed the
// turn with the task text); reaching here means the interception
// was bypassed, so report rather than silently dropping the task.
BuiltinAction::SwarmTask { prompt } => {
tracing::warn!(
prompt_len = prompt.len(),
"/swarm <task> reached the builtin executor; the turn seam did not intercept it"
);
self.send_slash_command_output(
"Could not start a swarm turn for that task. Run `/swarm on` and send the \
task as a normal message.",
)
.await;
ok_end_turn(0, None)
}
BuiltinAction::GoalStatus => {
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
let goal_tokens = self.goal_tokens_used(current_tokens);
@@ -1115,6 +1115,7 @@ pub(crate) async fn spawn_session_actor(
goal_tracker,
graph_enabled,
graph_tracker,
swarm_mode: std::cell::Cell::new(Default::default()),
graph_concurrency: effective_config.resolve_graph_concurrency(),
graph_node_rounds: effective_config.resolve_graph_node_rounds(),
graph_replan_cap: effective_config.resolve_graph_replan_cap(),
@@ -0,0 +1,80 @@
//! Swarm-mode seam: arming/disarming the mode and the reminder it injects.
use super::*;
use crate::session::swarm_mode::{SWARM_ENTER_REMINDER, SWARM_EXIT_REMINDER, SwarmTrigger};
impl SessionActor {
/// Arms (`Some(trigger)`) or disarms (`None`) swarm mode, injecting or
/// retracting the doctrine exactly once, and returns the line to show.
pub(crate) fn apply_swarm_mode(self: &Arc<Self>, trigger: Option<SwarmTrigger>) -> String {
let mut mode = self.swarm_mode.get();
let message = match trigger {
Some(trigger) => {
if mode.enter(trigger) {
self.inject_swarm_reminder(SWARM_ENTER_REMINDER);
}
"Swarm mode on: work will be split across a fleet of subagents. \
`/swarm off` to stop."
}
None => {
// Unconditional, unlike the automatic expiry: the doctrine
// rides the conversation and therefore survives a resume, a
// fork and a compaction that the in-memory flag does not. If
// the user explicitly asks for it off, the retraction has to
// reach the model even when this session never saw it armed.
mode.exit();
self.inject_swarm_reminder(SWARM_EXIT_REMINDER);
"Swarm mode off."
}
};
self.swarm_mode.set(mode);
message.to_string()
}
/// A turn-scoped guard that disarms a per-turn swarm mode however the turn
/// ends.
///
/// The post-loop call site is not enough: a user interrupt ABORTS the turn
/// future (`cancel_running_task` → `JoinHandle::abort`), dropping it at its
/// current await point, and several `?` paths return before the loop's end.
/// Each of those leaks a `/swarm <task>` mode into the user's next,
/// unrelated prompt. The goal engine hit the same class and compensates
/// inside the cancel path; a guard is the version that cannot be forgotten
/// at a new exit.
pub(crate) fn swarm_turn_guard(self: &Arc<Self>) -> SwarmTurnGuard {
SwarmTurnGuard {
session: self.clone(),
}
}
/// Disarms at a turn boundary when the trigger was per-turn.
pub(crate) fn expire_swarm_mode_at_turn_end(self: &Arc<Self>) {
if !self.swarm_mode.get().expires_at_turn_end() {
return;
}
let mut mode = self.swarm_mode.get();
if mode.exit() {
self.inject_swarm_reminder(SWARM_EXIT_REMINDER);
}
self.swarm_mode.set(mode);
}
/// The doctrine rides the session's existing `<system-reminder>` channel,
/// so it is tagged the same way every other reminder is and needs no
/// second injection path of its own.
fn inject_swarm_reminder(self: &Arc<Self>, text: &str) {
self.push_system_reminder(text);
}
}
/// Runs [`SessionActor::expire_swarm_mode_at_turn_end`] on every turn exit,
/// including an aborted future.
pub(crate) struct SwarmTurnGuard {
session: Arc<SessionActor>,
}
impl Drop for SwarmTurnGuard {
fn drop(&mut self) {
self.session.expire_swarm_mode_at_turn_end();
}
}
@@ -217,6 +217,10 @@ impl SessionActor {
persist_ack: Option<oneshot::Sender<()>>,
) -> PromptTurnResult {
let handle_prompt_start = std::time::Instant::now();
// Armed before anything can arm swarm mode, so a `/swarm <task>` mode
// is disarmed on EVERY exit from this turn — including the abort a
// user interrupt performs, which never reaches post-loop code.
let _swarm_turn_guard = self.swarm_turn_guard();
let prompt_length: usize = prompt_blocks
.iter()
.map(|b| match b {
@@ -301,6 +305,13 @@ impl SessionActor {
span.record("command_source", "builtin");
}
match action {
// `/swarm <task>` arms the mode for this turn only and
// sends the task as the prompt, so the doctrine is in the
// conversation before the model reads the work.
BuiltinAction::SwarmTask { prompt } => {
self.apply_swarm_mode(Some(crate::session::swarm_mode::SwarmTrigger::Task));
vec![text_block(prompt)]
}
BuiltinAction::GoalSet {
objective,
token_budget,
@@ -106,6 +106,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
tokio_util::sync::CancellationToken::new(),
);
let actor = Arc::new(SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info,
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
@@ -559,6 +560,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
};
let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
let actor = Arc::new(SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: session_info.clone(),
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
@@ -820,6 +822,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
)
.await;
let actor = SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-cancel"),
cwd: cwd.as_str().to_string(),
@@ -1813,6 +1816,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
)
.await;
let actor = SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-cancel-sampler"),
cwd: cwd.as_str().to_string(),
@@ -115,6 +115,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let actor = SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-idle-resume"),
cwd: cwd.as_str().to_string(),
@@ -64,6 +64,7 @@ async fn create_test_actor(
);
chat_state_handle.record_token_usage(total_tokens);
SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-auto-compact"),
cwd: cwd.as_str().to_string(),
@@ -493,6 +494,7 @@ async fn create_test_actor_with_memory(
.as_ref()
.map_or_else(Default::default, |mc| mc.initial_injection.clone());
SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-memory"),
cwd: cwd.as_str().to_string(),
@@ -1235,6 +1237,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let actor = SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-idle-resume"),
cwd: cwd.as_str().to_string(),
@@ -119,6 +119,7 @@ async fn create_test_actor_with_memory(
.as_ref()
.map_or_else(Default::default, |mc| mc.initial_injection.clone());
SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-memory"),
cwd: cwd.as_str().to_string(),
@@ -72,6 +72,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
});
let (event_tx, event_rx) = mpsc::unbounded_channel::<SessionEvent>();
let actor = SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-session"),
cwd: cwd.as_str().to_string(),
@@ -182,6 +182,7 @@ pub(crate) async fn create_test_actor_ex(
chat_state_handle.record_token_usage(total_tokens);
let (goal_update_tx, goal_update_rx) = tokio::sync::mpsc::unbounded_channel();
let actor = SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-actor"),
cwd: cwd.as_str().to_string(),
@@ -2154,6 +2154,7 @@ mod inline_auto_compact_flow_tests {
);
chat_state_handle.record_token_usage(total_tokens);
SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-auto-compact"),
cwd: cwd.as_str().to_string(),
@@ -329,6 +329,7 @@ pub(crate) mod slash_commands;
pub mod storage;
pub(crate) mod streaming_capture;
pub(crate) mod summary;
pub mod swarm_mode;
pub(crate) mod telemetry;
pub mod tool_index;
pub(crate) mod turn_completion;
@@ -45,6 +45,10 @@ pub(crate) enum BuiltinGate {
/// available (graph nodes execute as goals, so `/graph` needs
/// everything `/goal` needs).
Graph,
/// The `agent_swarm` tool is in the session toolset. The mode is only a
/// standing instruction to use that tool, so without it the command would
/// advertise a doctrine the model has no way to follow.
Swarm,
}
/// All built-in slash commands. Order here = display order in autocomplete.
@@ -303,6 +307,26 @@ pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[
}
},
},
BuiltinCommand {
name: "swarm",
description: "Delegate aggressively: split the work across a fleet of subagents",
argument_hint: Some("[on | off | <task>]"),
aliases: &[],
gate: BuiltinGate::Swarm,
resolve: |args| {
let trimmed = args.trim();
match trimmed.to_lowercase().as_str() {
"on" => BuiltinAction::SwarmSet { enabled: true },
"off" => BuiltinAction::SwarmSet { enabled: false },
"" => BuiltinAction::SwarmToggle,
// Anything else is the task itself: arm the mode for exactly
// this turn and send the text as the prompt.
_ => BuiltinAction::SwarmTask {
prompt: trimmed.to_string(),
},
}
},
},
];
/// Split a trailing `--budget <tokens>` flag off a `/goal` objective.
@@ -437,6 +461,8 @@ pub(crate) struct CommandAvailability {
/// `/graph` gate: the graph feature flag AND the goal harness (nodes
/// execute as goals) are both available.
pub graph: bool,
/// `/swarm` gate: the `agent_swarm` tool is in the active toolset.
pub swarm: bool,
}
impl CommandAvailability {
@@ -452,6 +478,7 @@ impl CommandAvailability {
BuiltinGate::Plugins => self.plugins,
BuiltinGate::Goal => self.goal,
BuiltinGate::Graph => self.graph,
BuiltinGate::Swarm => self.swarm,
}
}
@@ -468,6 +495,7 @@ impl CommandAvailability {
plugins: true,
goal: true,
graph: true,
swarm: true,
}
}
}
@@ -705,6 +733,17 @@ pub(super) enum BuiltinAction {
token_budget: Option<i64>,
},
GoalStatus,
/// `/swarm on|off` — arm or disarm the standing delegate-aggressively
/// instruction. Survives turns until switched off.
SwarmSet {
enabled: bool,
},
/// `/swarm` with no argument.
SwarmToggle,
/// `/swarm <task>` — arm for this turn only, then send `prompt`.
SwarmTask {
prompt: String,
},
GoalPause,
GoalResume,
GoalClear,
@@ -757,6 +796,9 @@ impl BuiltinAction {
| BuiltinAction::GraphPause
| BuiltinAction::GraphResume { .. }
| BuiltinAction::GraphClear => "graph",
BuiltinAction::SwarmSet { .. }
| BuiltinAction::SwarmToggle
| BuiltinAction::SwarmTask { .. } => "swarm",
}
}
@@ -795,6 +837,8 @@ impl BuiltinAction {
| BuiltinAction::GraphShow
| BuiltinAction::GraphPause
| BuiltinAction::GraphClear => false,
BuiltinAction::SwarmToggle => false,
BuiltinAction::SwarmSet { .. } | BuiltinAction::SwarmTask { .. } => true,
}
}
}
@@ -1584,6 +1628,7 @@ mod tests {
"feedback",
"goal",
"graph",
"swarm",
"loop",
"commit",
"deploy",
@@ -1694,6 +1739,51 @@ mod tests {
);
}
/// Without the `agent_swarm` tool the mode has nothing to steer toward, so
/// the command must fall through as ordinary prompt text rather than
/// arming a doctrine the model cannot act on.
#[test]
fn swarm_does_not_resolve_when_gate_off() {
let availability = CommandAvailability {
swarm: false,
..CommandAvailability::all_enabled()
};
assert!(
resolve(
vec![text_block("/swarm on")],
&[],
availability,
SkillSlashRewrite::default(),
)
.is_ok(),
"expected pass-through (Ok), got an outcome",
);
}
#[test]
fn swarm_resolves_each_form_to_its_own_action() {
assert!(matches!(
resolve_builtin("swarm", "on").expect("/swarm on must resolve"),
BuiltinAction::SwarmSet { enabled: true }
));
assert!(matches!(
resolve_builtin("swarm", "off").expect("/swarm off must resolve"),
BuiltinAction::SwarmSet { enabled: false }
));
assert!(matches!(
resolve_builtin("swarm", "").expect("bare /swarm must resolve"),
BuiltinAction::SwarmToggle
));
// Anything else is the task, NOT an unknown subcommand: mis-parsing it
// would silently drop the user's work instead of running it.
match resolve_builtin("swarm", "split the auth refactor")
.expect("/swarm <task> must resolve")
{
BuiltinAction::SwarmTask { prompt } => assert_eq!(prompt, "split the auth refactor"),
other => panic!("expected SwarmTask, got {}", other.command_name()),
}
}
#[test]
fn graph_resolves_subcommands_and_budget() {
let set = resolve_builtin("graph", "ship the feature --budget 5000")
@@ -0,0 +1,178 @@
//! Swarm mode: a standing instruction to split work across a fleet.
//!
//! The `agent_swarm` tool works with the mode off; what the mode adds is the
//! doctrine — decompose finely, give every member a disjoint scope, do not do
//! the work yourself. Kept as pure state so the turn loop decides when to
//! inject and the session decides when to persist.
use serde::{Deserialize, Serialize};
/// Why the mode is on, which is what decides when it turns off.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SwarmTrigger {
/// `/swarm on` (or a bare `/swarm` toggle): stays until switched off.
Manual,
/// `/swarm <task>`: armed for exactly that turn.
Task,
}
/// The session's swarm-mode state. `None` means off.
///
/// Deliberately NOT persisted, unlike `/goal` and `/graph`: those drive
/// autonomous multi-turn work that is stranded if it is lost, whereas this is
/// a prompt hint whose worst-case recovery is typing `/swarm on` again. The
/// injected doctrine IS durable (it rides the conversation), so a resumed
/// session can read as "off" with the instruction still in context — which is
/// exactly why an explicit `/swarm off` always retracts (see
/// [`SessionActor::apply_swarm_mode`]) rather than trusting a remembered flag
/// that a restore, a compaction or a fork can each falsify.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SwarmMode {
trigger: Option<SwarmTrigger>,
}
impl SwarmMode {
pub fn is_active(self) -> bool {
self.trigger.is_some()
}
pub fn trigger(self) -> Option<SwarmTrigger> {
self.trigger
}
/// Arms the mode, and does NOTHING if it is already armed.
///
/// Total no-op, not just "don't re-inject": overwriting the trigger would
/// let the `/swarm <task>` shorthand downgrade a standing `/swarm on` into
/// a per-turn mode, which then disarms itself at that turn's end — the
/// user's deliberate choice, silently undone.
///
/// Returns whether the caller should inject the enter reminder.
pub fn enter(&mut self, trigger: SwarmTrigger) -> bool {
if self.trigger.is_some() {
return false;
}
self.trigger = Some(trigger);
true
}
/// Disarms. Returns whether the mode had been armed — which is what an
/// AUTOMATIC expiry keys its retraction off. An explicit `/swarm off` must
/// retract regardless (see the type docs).
pub fn exit(&mut self) -> bool {
self.trigger.take().is_some()
}
/// Whether a turn ending now should disarm the mode.
///
/// Only the `Task` trigger auto-exits: `/swarm on` is a standing choice the
/// user made and a turn boundary is not a reason to undo it.
pub fn expires_at_turn_end(self) -> bool {
self.trigger == Some(SwarmTrigger::Task)
}
}
/// The doctrine injected when the mode is armed.
///
/// Deliberately short: it is re-read on every turn it is live, and the tool's
/// own description already carries the mechanics.
pub const SWARM_ENTER_REMINDER: &str = "\
Swarm mode is on. Explore only as far as you must to identify the work, then \
split it: use the agent_swarm tool with one item per independent scope rather \
than doing the work yourself. Decompose finely do not try to conserve \
members. Every member must own a disjoint scope; members share one working \
tree, so two members told to touch the same file will corrupt each other. \
Read-only scopes may overlap. If the work genuinely does not split, say so and \
carry on alone.";
/// Injected when the mode is switched off mid-conversation, so the earlier
/// doctrine does not keep steering the model.
/// Deliberately as emphatic as the enter doctrine it revokes: a one-line
/// "mode is off" is the weaker of the two texts in context and the likelier to
/// be summarised away, leaving the fan-out directives still steering.
pub const SWARM_EXIT_REMINDER: &str = "\
Swarm mode is off. The swarm instructions above no longer apply you are not \
required to split work across subagents, and you should not decompose a task \
just because they said to. Decide how to approach each new request from the \
request itself. Delegate only where it clearly helps.";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_session_has_the_mode_off() {
let mode = SwarmMode::default();
assert!(!mode.is_active());
assert!(!mode.expires_at_turn_end());
}
#[test]
fn arming_asks_for_the_doctrine_once_not_once_per_command() {
let mut mode = SwarmMode::default();
assert!(mode.enter(SwarmTrigger::Manual), "first arm injects");
assert!(
!mode.enter(SwarmTrigger::Manual),
"a repeated /swarm on must not stack a second copy"
);
assert!(mode.is_active());
}
#[test]
fn only_the_task_trigger_expires_at_a_turn_boundary() {
let mut manual = SwarmMode::default();
manual.enter(SwarmTrigger::Manual);
assert!(
!manual.expires_at_turn_end(),
"/swarm on is a standing choice, not a per-turn one"
);
let mut task = SwarmMode::default();
task.enter(SwarmTrigger::Task);
assert!(task.expires_at_turn_end());
}
#[test]
fn exiting_retracts_exactly_once() {
let mut mode = SwarmMode::default();
mode.enter(SwarmTrigger::Manual);
assert!(mode.exit(), "the live doctrine must be retracted");
assert!(!mode.is_active());
assert!(
!mode.exit(),
"a second /swarm off has nothing left to retract"
);
}
/// `/swarm <task>` under a standing `/swarm on` must seed the turn and
/// nothing more — it must not convert the standing mode into a per-turn
/// one that disarms itself when that turn ends.
#[test]
fn the_task_shorthand_never_downgrades_a_standing_mode() {
let mut mode = SwarmMode::default();
mode.enter(SwarmTrigger::Manual);
assert!(
!mode.enter(SwarmTrigger::Task),
"the doctrine is already in the conversation"
);
assert_eq!(mode.trigger(), Some(SwarmTrigger::Manual));
assert!(
!mode.expires_at_turn_end(),
"the user's standing /swarm on must survive the turn"
);
}
/// An automatic expiry has nothing to retract once the mode is already
/// off; only an explicit `/swarm off` retracts unconditionally, and that
/// rule lives at the call site, not here.
#[test]
fn exit_reports_whether_it_actually_disarmed_something() {
let mut armed = SwarmMode::default();
armed.enter(SwarmTrigger::Task);
assert!(armed.exit());
let mut idle = SwarmMode::default();
assert!(!idle.exit());
}
}