Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/slash_commands.rs
T
ZacharyZhang-NY 206711bed5 fix(skills): a skill body stops at the 25k-token read cap
`read_file` exempted SKILL.md from every limit, and both injection paths
(`/skill` expansion and the `skill` tool) pasted the whole body. A 220 KB
skill put 55k tokens into the context on each call.

`cap_skill_body` cuts an over-cap body at the last line boundary that
fits (a char boundary when the body is one long line) and appends a note
that points at offset and limit. It runs before `apply_substitutions`, so
the arguments land after the cut. Both injection paths call it and log
the cut.

`read_file` returns a SKILL.md whole only while it fits the cap. Over the
cap it reads like any other file: offset and limit apply, and a window
that is still too large answers FileTooLarge. Without that the note would
send the model to a read that ignores its range.

`skill_file_skips_token_limit` pinned the exemption; it gives way to
`skill_file_over_cap_returns_file_too_large` and
`skill_file_over_cap_honors_offset_and_limit`, as upstream did.

Ported from upstream (grok-build tools skills/skill.rs cap_skill_body and
read_file skill_full, 1.0.33). Upstream's `max_output_bytes` read budget
in the same hunk is a feature and stays out.
2026-09-19 02:31:24 -04:00

2811 lines
99 KiB
Rust

//! ACP slash command advertising and resolution.
use agent_client_protocol as acp;
use kigi_tools::implementations::skills::skill::format_skill_name;
use kigi_tools::implementations::skills::types::SkillInfo;
pub(crate) struct BuiltinCommand {
pub name: &'static str,
pub description: &'static str,
pub argument_hint: Option<&'static str>,
pub aliases: &'static [&'static str],
/// Capability the agent must have for this command to be useful.
/// Filtered by `CommandAvailability::allows()` at advertising time;
/// commands that map to `BuiltinGate::AlwaysOn` are never gated.
pub gate: BuiltinGate,
resolve: fn(args: &str) -> BuiltinAction,
}
/// Capability gate that decides whether a `BuiltinCommand` is advertised
/// and resolvable in a given session.
///
/// Each variant maps to a feature/tool the agent must actually have:
/// - `Memory`: a memory backend is configured (`SessionMemory::is_enabled`).
/// - `Scheduler`: `scheduler_create` is registered.
/// - `Hooks`: a hook registry is loaded.
/// - `Plugins`: a plugin registry is loaded.
/// - `Feedback`: the feedback manager is enabled.
/// - `MemoryConfigured`: memory backend params exist (may be currently
/// disabled). Used for `/memory` so the user can re-enable via toggle.
/// - `Goal`: `resolve_goal()` feature flag is on AND `update_goal` is in the
/// session toolset (see `goal_slash_and_harness_available` in `acp_session.rs`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BuiltinGate {
AlwaysOn,
Feedback,
Memory,
MemoryConfigured,
/// Checks `scheduler_create` only. If any future shell-side builtin
/// needs a separate scheduler-delete gate, add a `SchedulerDelete` variant.
Scheduler,
Hooks,
Plugins,
Goal,
/// `resolve_graph()` feature flag is on AND the goal harness is
/// 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.
pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[
BuiltinCommand {
name: "compact",
description: "Compress conversation history to save context window",
argument_hint: Some("optional context about what to preserve"),
aliases: &[],
gate: BuiltinGate::AlwaysOn,
resolve: |args| BuiltinAction::Compact {
user_context: if args.is_empty() {
None
} else {
Some(args.to_string())
},
},
},
BuiltinCommand {
name: "always-approve",
description: "Toggle always-approve mode (skip all permission prompts)",
argument_hint: Some("on|off"),
aliases: &["yolo"],
gate: BuiltinGate::AlwaysOn,
resolve: |args| BuiltinAction::SetYolo {
enabled: !matches!(
args.to_lowercase().as_str(),
"off" | "false" | "0" | "no" | "disable"
),
},
},
BuiltinCommand {
name: "flush",
description: "Flush conversation memory to disk now",
argument_hint: None,
aliases: &[],
gate: BuiltinGate::Memory,
resolve: |_args| BuiltinAction::FlushMemory,
},
BuiltinCommand {
name: "dream",
description: "Run memory consolidation (merge session logs into organized topics)",
argument_hint: None,
aliases: &[],
gate: BuiltinGate::Memory,
resolve: |_args| BuiltinAction::Dream,
},
BuiltinCommand {
name: "memory",
description: "Browse, view, and manage your memories",
argument_hint: Some("on|off"),
aliases: &["mem"],
gate: BuiltinGate::MemoryConfigured,
resolve: |args| {
let trimmed = args.trim().to_lowercase();
match trimmed.as_str() {
"on" | "enable" => BuiltinAction::MemoryToggle { enabled: true },
"off" | "disable" => BuiltinAction::MemoryToggle { enabled: false },
_ => BuiltinAction::MemoryBrowse,
}
},
},
BuiltinCommand {
name: "context",
description: "Show context window usage and session stats",
argument_hint: None,
aliases: &[],
gate: BuiltinGate::AlwaysOn,
resolve: |_args| BuiltinAction::ContextInfo,
},
BuiltinCommand {
name: "hooks-trust",
description: "Trust this project for hook execution",
argument_hint: None,
aliases: &[],
gate: BuiltinGate::Hooks,
resolve: |_args| BuiltinAction::HooksTrust,
},
BuiltinCommand {
name: "hooks-list",
description: "Show hooks loaded in this session",
argument_hint: None,
aliases: &[],
gate: BuiltinGate::Hooks,
resolve: |_args| BuiltinAction::HooksList,
},
BuiltinCommand {
name: "hooks-add",
description: "Add a custom hook file or directory",
argument_hint: Some("path to hook file or directory"),
aliases: &[],
gate: BuiltinGate::Hooks,
resolve: |args| BuiltinAction::HooksAdd {
path: args.trim().to_string(),
},
},
BuiltinCommand {
name: "hooks-remove",
description: "Remove a custom hook file or directory path",
argument_hint: Some("path to hook file or directory"),
aliases: &[],
gate: BuiltinGate::Hooks,
resolve: |args| BuiltinAction::HooksRemove {
path: args.trim().to_string(),
},
},
BuiltinCommand {
name: "hooks-untrust",
description: "Remove trust for the current project",
argument_hint: None,
aliases: &[],
gate: BuiltinGate::Hooks,
resolve: |_args| BuiltinAction::HooksUntrust,
},
BuiltinCommand {
name: "plugins",
description: "Manage plugins (list, reload, trust, add, remove)",
argument_hint: Some("list | reload | trust <path> | add <path> | remove <path>"),
aliases: &["plugin"],
gate: BuiltinGate::Plugins,
resolve: |args| {
let trimmed = args.trim();
if trimmed.is_empty() || trimmed == "list" {
BuiltinAction::PluginsList
} else if trimmed == "reload" {
BuiltinAction::PluginsReload
} else if trimmed.starts_with("trust") {
BuiltinAction::PluginsTrust
} else if let Some(path) = trimmed.strip_prefix("add ") {
BuiltinAction::PluginsAdd {
path: path.trim().to_string(),
}
} else if let Some(path) = trimmed.strip_prefix("remove ") {
BuiltinAction::PluginsRemove {
path: path.trim().to_string(),
}
} else if let Some(args) = trimmed.strip_prefix("install ") {
let args = args.trim();
let trust = args.ends_with(" --trust") || args == "--trust";
let source = if trust {
args.trim_end_matches(" --trust").trim().to_string()
} else {
args.to_string()
};
BuiltinAction::PluginsInstall { source, trust }
} else if let Some(args) = trimmed.strip_prefix("uninstall ") {
let args = args.trim();
let confirm = args.ends_with(" --confirm") || args == "--confirm";
let name = if confirm {
args.trim_end_matches(" --confirm").trim().to_string()
} else {
args.to_string()
};
BuiltinAction::PluginsUninstall { name, confirm }
} else if trimmed == "update" {
BuiltinAction::PluginsUpdate { name: None }
} else if let Some(name) = trimmed.strip_prefix("update ") {
BuiltinAction::PluginsUpdate {
name: Some(name.trim().to_string()),
}
} else {
BuiltinAction::PluginsList
}
},
},
BuiltinCommand {
name: "reload-plugins",
description: "Reload plugins from disk (alias for /plugins reload)",
argument_hint: None,
aliases: &[],
gate: BuiltinGate::Plugins,
resolve: |_args| BuiltinAction::PluginsReload,
},
BuiltinCommand {
name: "session-info",
description: "Show session details (model, turns, context usage)",
argument_hint: None,
aliases: &["status", "info"],
gate: BuiltinGate::AlwaysOn,
resolve: |_args| BuiltinAction::SessionInfo,
},
BuiltinCommand {
name: "feedback",
description: "Send feedback about the current session",
argument_hint: Some("feedback text"),
aliases: &[],
gate: BuiltinGate::Feedback,
resolve: |args| BuiltinAction::Feedback {
text: args.trim().to_string(),
},
},
BuiltinCommand {
name: "goal",
description: "Set, manage, or check an autonomous goal",
argument_hint: Some("<objective> [--budget <tokens>] | status | pause | resume | clear"),
aliases: &[],
gate: BuiltinGate::Goal,
resolve: |args| {
let trimmed = args.trim();
match trimmed.to_lowercase().as_str() {
"" | "status" => BuiltinAction::GoalStatus,
"pause" => BuiltinAction::GoalPause,
"resume" => BuiltinAction::GoalResume,
"clear" => BuiltinAction::GoalClear,
_ => {
let (objective, token_budget) = parse_goal_budget(trimmed);
BuiltinAction::GoalSet {
objective,
token_budget,
}
}
}
},
},
BuiltinCommand {
name: "graph",
description: "Decompose an objective into a dependency graph of autonomous goals",
argument_hint: Some(
"<objective> [--budget <tokens>] | status | pause | resume [--budget <tokens>] | clear",
),
aliases: &[],
gate: BuiltinGate::Graph,
resolve: |args| {
let trimmed = args.trim();
match trimmed.to_lowercase().as_str() {
"" | "status" => BuiltinAction::GraphStatus,
"show" => BuiltinAction::GraphShow,
"pause" => BuiltinAction::GraphPause,
"resume" => BuiltinAction::GraphResume { extra_budget: None },
"clear" => BuiltinAction::GraphClear,
_ => {
// ANY input starting with `resume` is a resume attempt
// and must NEVER fall through to GraphSet — a typo'd
// top-up would otherwise silently replace a resumable
// BudgetLimited graph. Well-formed `resume --budget
// <tokens>` (case-insensitive keywords) carries the
// top-up; malformed variants resolve to a plain
// resume, whose BudgetLimited arm prints the usage.
let lower = trimmed.to_lowercase();
if let Some(rest) = lower.strip_prefix("resume") {
let extra_budget = rest
.trim()
.strip_prefix("--budget")
.map(str::trim)
.filter(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()))
.and_then(|v| v.parse::<i64>().ok())
.filter(|extra| *extra > 0);
return BuiltinAction::GraphResume { extra_budget };
}
let (objective, token_budget) = parse_goal_budget(trimmed);
BuiltinAction::GraphSet {
objective,
token_budget,
}
}
}
},
},
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.
///
/// Only a TRAILING, standalone flag is consumed: the flag must be its own
/// whitespace-separated token and the value a final all-digit positive
/// token. Anything else stays part of the objective so a goal text that
/// merely mentions the flag is never silently mangled.
fn parse_goal_budget(trimmed: &str) -> (String, Option<i64>) {
if let Some((head, tail)) = trimmed.rsplit_once("--budget") {
let value = tail.trim();
let flag_is_own_token = head.ends_with(char::is_whitespace)
&& tail.starts_with(char::is_whitespace)
&& !value.contains(char::is_whitespace);
let head = head.trim_end();
if flag_is_own_token
&& !head.is_empty()
&& !value.is_empty()
&& value.bytes().all(|b| b.is_ascii_digit())
&& let Ok(budget) = value.parse::<i64>()
&& budget > 0
{
return (head.to_string(), Some(budget));
}
}
(trimmed.to_string(), None)
}
const PROMPT_COMMANDS: &[BuiltinCommand] = &[BuiltinCommand {
name: "loop",
description: "Run a prompt on a recurring interval",
argument_hint: Some("[interval] <prompt>"),
aliases: &[],
gate: BuiltinGate::Scheduler,
// INVARIANT: resolve() short-circuits any prompt-only command via a
// PROMPT_COMMANDS lookup before reaching this closure. If a future
// refactor changes that ordering this `unreachable!` will surface
// the bug loudly instead of silently dispatching to ContextInfo
// (which is what the previous sentinel did).
resolve: |_| unreachable!("/loop is dispatched via the PROMPT_COMMANDS path in resolve()"),
}];
/// A slash command — either built-in or from a SKILL.md file.
pub(super) enum SlashCommand<'a> {
BuiltIn(&'a BuiltinCommand),
Skill(&'a SkillInfo),
}
impl<'a> SlashCommand<'a> {
pub fn name(&self) -> &str {
match self {
SlashCommand::BuiltIn(b) => b.name,
SlashCommand::Skill(s) => &s.name,
}
}
pub fn description(&self) -> &str {
match self {
SlashCommand::BuiltIn(b) => b.description,
SlashCommand::Skill(s) => s.short_description.as_deref().unwrap_or(&s.description),
}
}
pub fn argument_hint(&self) -> Option<&str> {
match self {
SlashCommand::BuiltIn(b) => b.argument_hint,
SlashCommand::Skill(s) => s.argument_hint.as_deref(),
}
}
}
/// Builtins first (win on name collisions), then user-invocable skills.
///
/// `availability` filters tool/extension-gated builtins so commands like
/// `/flush` and `/loop` only show up when the agent
/// actually has the backing capability. Always-on builtins are
/// unaffected.
pub(super) fn all_commands<'a>(
skills: &'a [SkillInfo],
availability: CommandAvailability,
) -> Vec<SlashCommand<'a>> {
let mut commands: Vec<SlashCommand<'_>> = BUILTIN_COMMANDS
.iter()
.filter(|b| availability.allows(b.gate))
.map(SlashCommand::BuiltIn)
.collect();
commands.extend(
PROMPT_COMMANDS
.iter()
.filter(|b| availability.allows(b.gate))
.map(SlashCommand::BuiltIn),
);
commands.extend(
skills
.iter()
.filter(|s| s.user_invocable && s.enabled)
.map(SlashCommand::Skill),
);
commands
}
/// Per-session capability snapshot used to gate which built-in slash
/// commands the shell advertises and resolves.
///
/// Each field corresponds to a `BuiltinGate` variant. Construct via
/// `CommandAvailability::all_enabled()` for tests, or build it from a
/// live `SessionActor` (see the call site in `acp_session.rs`).
///
/// `Default` returns every gate disabled (fail-closed) so a forgotten
/// initialization advertises only `BuiltinGate::AlwaysOn` commands.
/// In test code, prefer `all_enabled()` when the gating itself isn't
/// under test -- otherwise the test will silently lose coverage of any
/// gated builtin.
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct CommandAvailability {
pub feedback: bool,
/// Memory backend is enabled AND the active toolset includes the
/// memory read tools. `/flush` and `/dream` only make sense when the
/// model can later read back what they wrote, so the read-side tool
/// presence is the right signal -- harnesses that don't register
/// `memory_search`/`memory_get` get the commands hidden without the
/// gating layer needing to know about agent_type.
pub memory: bool,
/// Memory backend is configured (has `backend_params`) but not
/// necessarily currently enabled. Gates `/memory` (browse + toggle)
/// so the user can re-enable memory after toggling it off.
pub memory_configured: bool,
pub scheduler: bool,
pub hooks: bool,
pub plugins: bool,
pub goal: bool,
/// `/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 {
/// `true` if commands gated on `gate` should be advertised this session.
pub fn allows(&self, gate: BuiltinGate) -> bool {
match gate {
BuiltinGate::AlwaysOn => true,
BuiltinGate::Feedback => self.feedback,
BuiltinGate::Memory => self.memory,
BuiltinGate::MemoryConfigured => self.memory_configured,
BuiltinGate::Scheduler => self.scheduler,
BuiltinGate::Hooks => self.hooks,
BuiltinGate::Plugins => self.plugins,
BuiltinGate::Goal => self.goal,
BuiltinGate::Graph => self.graph,
BuiltinGate::Swarm => self.swarm,
}
}
/// Test helper: every gate satisfied (matches the legacy "feedback only"
/// fixture but enables every newly-gated command too).
#[cfg(test)]
pub fn all_enabled() -> Self {
Self {
feedback: true,
memory: true,
memory_configured: true,
scheduler: true,
hooks: true,
plugins: true,
goal: true,
graph: true,
swarm: true,
}
}
}
/// Build the JSON value for `AvailableCommandsUpdate.meta` containing the
/// agent's currently-registered tool names.
///
/// Wire format: `{"tools": ["read_file", "scheduler_create", ...]}`.
/// Pager clients drain this and call `CommandRegistry::set_available_tools`
/// to gate tool-dependent commands like `/loop`.
///
/// Takes `&[String]` rather than `&[&str]` because serde_json copies
/// each entry into the `Value` regardless, so an intermediate
/// `Vec<&str>` adapter would just waste an allocation.
pub(crate) fn build_tools_meta(tool_names: &[String]) -> acp::Meta {
let mut meta = acp::Meta::new();
meta.insert("tools".to_owned(), serde_json::json!(tool_names));
meta
}
/// Build the ACP `AvailableCommand` list for the client autocomplete menu.
///
/// Skills include `scope` and `path` in `_meta` so the client can show
/// where the command comes from (e.g. "project" vs "global") and link
/// to the SKILL.md source.
pub(super) fn available_commands(
skills: &[SkillInfo],
availability: CommandAvailability,
) -> Vec<acp::AvailableCommand> {
// Detect duplicate bare names among user-invocable skills so we can
// advertise qualified names (e.g. "local:commit") when ambiguous.
let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for s in skills.iter().filter(|s| s.user_invocable) {
*name_counts.entry(&s.name).or_default() += 1;
}
// Collect builtin command names so skills that collide are also qualified.
let builtin_names: std::collections::HashSet<&str> =
BUILTIN_COMMANDS.iter().map(|b| b.name).collect();
all_commands(skills, availability)
.iter()
.flat_map(|cmd| {
let entries: Vec<acp::AvailableCommand> = match cmd {
SlashCommand::BuiltIn(b) => {
vec![
acp::AvailableCommand::new(
b.name.to_string(),
cmd.description().to_string(),
)
.input(cmd.argument_hint().map(|hint| {
acp::AvailableCommandInput::Unstructured(
acp::UnstructuredCommandInput::new(hint.to_string()),
)
})),
]
}
SlashCommand::Skill(s) => {
let meta = serde_json::json!({
"scope": s.scope,
"path": s.path,
})
.as_object()
.cloned();
let qualified = format_skill_name(s);
let bare_collides = name_counts.get(s.name.as_str()).copied().unwrap_or(0) > 1
|| builtin_names.contains(s.name.as_str());
let make_entry = |name: String| {
acp::AvailableCommand::new(name, cmd.description().to_string())
.input(cmd.argument_hint().map(|hint| {
acp::AvailableCommandInput::Unstructured(
acp::UnstructuredCommandInput::new(hint.to_string()),
)
}))
.meta(meta.clone())
};
// Collision: advertise the qualified name only.
// No collision: advertise the bare name only.
if bare_collides {
vec![make_entry(qualified)]
} else {
// No collision — bare name only. The qualified form
// would just duplicate the entry in autocomplete.
vec![make_entry(s.name.clone())]
}
}
};
entries
})
.collect()
}
/// Pre-session builtin commands for `InitializeResponse._meta`.
///
/// Advertises every always-on command plus any gated command whose gate
/// is satisfied by `availability`. Pre-session, only config-derived gates
/// (e.g. `goal`, which is driven by the `resolve_goal()` feature flag and
/// not by a live toolset) can be evaluated; runtime/tool-dependent gates
/// stay closed because there's no session context yet. See
/// `MvpAgent::command_availability` for how the pre-session snapshot is
/// built. With `CommandAvailability::default()` (all gates closed) this
/// is equivalent to advertising only `BuiltinGate::AlwaysOn` commands.
pub(crate) fn builtin_commands(availability: CommandAvailability) -> Vec<acp::AvailableCommand> {
BUILTIN_COMMANDS
.iter()
.filter(|cmd| availability.allows(cmd.gate))
.map(|cmd| {
acp::AvailableCommand::new(cmd.name.to_string(), cmd.description.to_string()).input(
cmd.argument_hint.map(|hint| {
acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
hint.to_string(),
))
}),
)
})
.collect()
}
#[derive(serde::Deserialize)]
pub(crate) struct ListCommandsRequest {
pub cwd: Option<String>,
}
#[derive(serde::Serialize)]
pub(crate) struct ListCommandsResponse {
pub commands: Vec<acp::AvailableCommand>,
}
/// Build the available commands list, optionally scoped to a working directory.
/// - `Some(cwd)`: full skill discovery (Local + Repo + User) + builtins.
/// - `None`: builtins + global (User-scoped) skills only.
pub(crate) async fn list_commands(
cwd: Option<&str>,
skills_config: &kigi_agent::prompt::skills::SkillsConfig,
plugin_registry: Option<&kigi_agent::plugins::PluginRegistry>,
availability: CommandAvailability,
compat: kigi_tools::types::compat::CompatConfig,
) -> ListCommandsResponse {
let skills = kigi_agent::prompt::skills::list_skills_with_plugins(
cwd,
skills_config,
plugin_registry,
compat,
)
.await;
ListCommandsResponse {
commands: available_commands(&skills, availability),
}
}
/// A parsed skill reference from user input.
///
/// Produced by `parse_skill_references()` when scanning user text for known
/// `/{skill_name}` tokens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ParsedSkillRef {
/// The skill name (bare or qualified, as typed by the user).
pub name: String,
/// Arguments following this skill reference, up to the next skill or end-of-input.
pub args: String,
/// The resolved `SkillInfo` path, for loading SKILL.md.
pub skill_path: String,
/// Scope-qualified name (e.g. "user:commit"), used for telemetry.
pub qualified_name: String,
/// Plugin name if this is a plugin skill.
pub plugin_name: Option<String>,
}
pub(super) enum SlashCommandOutcome {
/// Execute directly, no model round-trip.
Builtin(BuiltinAction),
/// One or more skills detected in user input.
///
/// The original prompt `blocks` are preserved verbatim — they are NOT
/// rewritten. The shell's prompt assembly layer will read each skill's
/// SKILL.md, apply substitutions, and build the `<skill_information>`
/// envelope alongside the `<user_query>` block.
InvokeSkill {
/// The original, unmodified prompt blocks.
blocks: Vec<acp::ContentBlock>,
/// Parsed skill references (one per detected `/{skill}` token).
skills: Vec<ParsedSkillRef>,
},
}
pub(super) enum BuiltinAction {
Compact {
user_context: Option<String>,
},
SetYolo {
enabled: bool,
},
FlushMemory,
Dream,
ContextInfo,
HooksTrust,
HooksList,
HooksAdd {
path: String,
},
HooksRemove {
path: String,
},
HooksUntrust,
PluginsList,
PluginsReload,
PluginsTrust,
SessionInfo,
PluginsAdd {
path: String,
},
PluginsRemove {
path: String,
},
PluginsInstall {
source: String,
trust: bool,
},
PluginsUninstall {
name: String,
confirm: bool,
},
PluginsUpdate {
name: Option<String>,
},
Feedback {
text: String,
},
MemoryBrowse,
MemoryToggle {
enabled: bool,
},
GoalSet {
objective: String,
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,
GraphSet {
objective: String,
token_budget: Option<i64>,
},
GraphStatus,
GraphShow,
GraphPause,
GraphResume {
extra_budget: Option<i64>,
},
GraphClear,
}
impl BuiltinAction {
pub(crate) fn command_name(&self) -> &'static str {
match self {
BuiltinAction::Compact { .. } => "compact",
BuiltinAction::SetYolo { .. } => "yolo",
BuiltinAction::FlushMemory => "flush",
BuiltinAction::Dream => "dream",
BuiltinAction::ContextInfo => "context",
BuiltinAction::HooksTrust => "hooks-trust",
BuiltinAction::HooksList => "hooks-list",
BuiltinAction::HooksAdd { .. } => "hooks-add",
BuiltinAction::HooksRemove { .. } => "hooks-remove",
BuiltinAction::HooksUntrust => "hooks-untrust",
BuiltinAction::PluginsList => "plugins-list",
BuiltinAction::PluginsReload => "plugins-reload",
BuiltinAction::PluginsTrust => "plugins-trust",
BuiltinAction::SessionInfo => "session",
BuiltinAction::PluginsAdd { .. } => "plugins-add",
BuiltinAction::PluginsRemove { .. } => "plugins-remove",
BuiltinAction::PluginsInstall { .. } => "plugins-install",
BuiltinAction::PluginsUninstall { .. } => "plugins-uninstall",
BuiltinAction::PluginsUpdate { .. } => "plugins-update",
BuiltinAction::Feedback { .. } => "feedback",
BuiltinAction::MemoryBrowse => "memory",
BuiltinAction::MemoryToggle { .. } => "memory",
BuiltinAction::GoalSet { .. }
| BuiltinAction::GoalStatus
| BuiltinAction::GoalPause
| BuiltinAction::GoalResume
| BuiltinAction::GoalClear => "goal",
BuiltinAction::GraphSet { .. }
| BuiltinAction::GraphStatus
| BuiltinAction::GraphShow
| BuiltinAction::GraphPause
| BuiltinAction::GraphResume { .. }
| BuiltinAction::GraphClear => "graph",
BuiltinAction::SwarmSet { .. }
| BuiltinAction::SwarmToggle
| BuiltinAction::SwarmTask { .. } => "swarm",
}
}
pub(crate) fn args_provided(&self) -> bool {
match self {
BuiltinAction::Compact { user_context } => user_context.is_some(),
BuiltinAction::SetYolo { .. } => true,
BuiltinAction::FlushMemory => false,
BuiltinAction::Dream => false,
BuiltinAction::ContextInfo => false,
BuiltinAction::HooksTrust => false,
BuiltinAction::HooksList => false,
BuiltinAction::HooksAdd { .. } => true,
BuiltinAction::HooksRemove { .. } => true,
BuiltinAction::HooksUntrust => false,
BuiltinAction::PluginsList => false,
BuiltinAction::PluginsReload => false,
BuiltinAction::PluginsTrust => false,
BuiltinAction::SessionInfo => false,
BuiltinAction::PluginsAdd { .. } => true,
BuiltinAction::PluginsRemove { .. } => true,
BuiltinAction::PluginsInstall { .. } => true,
BuiltinAction::PluginsUninstall { .. } => true,
BuiltinAction::PluginsUpdate { name } => name.is_some(),
BuiltinAction::Feedback { text } => !text.is_empty(),
BuiltinAction::MemoryBrowse => false,
BuiltinAction::MemoryToggle { .. } => true,
BuiltinAction::GoalSet { .. } => true,
BuiltinAction::GoalStatus
| BuiltinAction::GoalPause
| BuiltinAction::GoalResume
| BuiltinAction::GoalClear => false,
BuiltinAction::GraphSet { .. } => true,
BuiltinAction::GraphResume { extra_budget } => extra_budget.is_some(),
BuiltinAction::GraphStatus
| BuiltinAction::GraphShow
| BuiltinAction::GraphPause
| BuiltinAction::GraphClear => false,
BuiltinAction::SwarmToggle => false,
BuiltinAction::SwarmSet { .. } | BuiltinAction::SwarmTask { .. } => true,
}
}
}
/// How to rewrite the user's prompt when a slash command resolves to a skill.
///
/// - `RewriteToRun` (default): replace `/foo args` with `"run /foo args"`,
/// matching today's Kigi flow that calls our dedicated `skill` tool.
/// - `Passthrough`: leave the prompt verbatim. Some templates use this —
/// the model is trained to spot a leading `/<name>`, look it up in the
/// `<agent_skills>` listing, and call the Read tool on `fullPath`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum SkillSlashRewrite {
#[default]
RewriteToRun,
Passthrough,
}
/// Scan user input left-to-right for `/{word}` tokens where `word` matches
/// a **known registered skill name** (bare or qualified).
///
/// Unknown `/words` (like `/api/v2/users`, `/tmp/file`) are NOT treated as
/// skill references — only tokens that resolve to a known skill count.
///
/// Returns `None` when no known skill references are found. Otherwise returns
/// the list of `ParsedSkillRef` entries with each skill's args (the text
/// between one skill token and the next, or end-of-input).
pub(crate) fn parse_skill_references(
text: &str,
skills: &[SkillInfo],
availability: CommandAvailability,
) -> Option<Vec<ParsedSkillRef>> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
let commands = all_commands(skills, availability);
// Build a map from bare name → SkillInfo reference, tracking ambiguous
// names (multiple skills sharing the same bare name). Ambiguous bare
// names are excluded from the map so `/commit` passes through when two
// skills are both called "commit" in different scopes — the user must
// use the qualified form `/local:commit` instead.
let mut skill_map: std::collections::HashMap<&str, Option<&SkillInfo>> =
std::collections::HashMap::new();
for cmd in &commands {
if let SlashCommand::Skill(s) = cmd {
skill_map
.entry(&s.name)
// duplicate → mark ambiguous
.and_modify(|v| *v = None)
.or_insert(Some(s));
}
}
// Remove ambiguous entries so they're never matched by bare name.
skill_map.retain(|_, v| v.is_some());
// Collect positions of all /{word} tokens, checking if each matches a known skill.
struct SkillHit<'a> {
/// Byte offset of the '/' in the source text.
offset: usize,
/// The text as typed by the user (e.g. "commit", "user:commit").
typed_name: String,
skill: &'a SkillInfo,
}
let mut hits: Vec<SkillHit<'_>> = Vec::new();
let bytes = trimmed.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'/' {
i += 1;
continue;
}
// Must be at start of text or preceded by whitespace.
if i > 0 && !bytes[i - 1].is_ascii_whitespace() {
i += 1;
continue;
}
let start = i + 1;
if start >= bytes.len() {
break;
}
// Grab the word: everything until whitespace or end.
let end = trimmed[start..]
.find(|c: char| c.is_whitespace())
.map(|rel| start + rel)
.unwrap_or(trimmed.len());
let word = &trimmed[start..end];
if word.is_empty() {
i = start;
continue;
}
// Check if it's a builtin — skip those (builtins are not multi-skill candidates).
let is_builtin = BUILTIN_COMMANDS
.iter()
.chain(PROMPT_COMMANDS.iter())
.any(|b| b.name == word || b.aliases.contains(&word));
if is_builtin {
i = end;
continue;
}
// Check bare name in map (only unambiguous entries remain).
if let Some(Some(skill)) = skill_map.get(word) {
hits.push(SkillHit {
offset: i,
typed_name: word.to_string(),
skill,
});
i = end;
continue;
}
// Check qualified name (e.g. "user:commit").
let mut found = false;
for cmd in &commands {
if let SlashCommand::Skill(s) = cmd
&& format_skill_name(s) == word
{
hits.push(SkillHit {
offset: i,
typed_name: word.to_string(),
skill: s,
});
found = true;
break;
}
}
if found {
i = end;
continue;
}
// Unknown /word — skip.
i = end;
}
if hits.is_empty() {
return None;
}
// Compute args for each hit: text from end-of-skill-token to start of next hit (or end).
let mut refs = Vec::with_capacity(hits.len());
for (idx, hit) in hits.iter().enumerate() {
// past the /word
let word_end = hit.offset + 1 + hit.typed_name.len();
let args_end = if idx + 1 < hits.len() {
hits[idx + 1].offset
} else {
trimmed.len()
};
let args = trimmed[word_end..args_end].trim().to_string();
refs.push(ParsedSkillRef {
name: hit.typed_name.clone(),
args,
skill_path: hit.skill.path.clone(),
qualified_name: format_skill_name(hit.skill),
plugin_name: hit.skill.plugin_name.clone(),
});
}
Some(refs)
}
/// Load each parsed skill's SKILL.md, apply substitutions, and build the
/// `<skill_information>` envelope.
///
/// Shared by turn start (prompt assembly in `process_conversation_turn`) and
/// the mid-turn interjection drain, so a skill delivers identically whether
/// it starts a turn or is force-sent into a running one. Returns `None` when
/// no skill content loads (missing files are logged and skipped; the
/// `<skills_referenced>` index still lists every parsed ref).
pub(super) async fn build_skill_information_for_refs(
parsed_skills: &[ParsedSkillRef],
slash_skills: &[SkillInfo],
session_id: &str,
) -> Option<String> {
use kigi_tools::implementations::skills::body_cap::cap_skill_body;
use kigi_tools::implementations::skills::skill::{
SkillRef, SubstitutionContext, apply_substitutions, build_skill_block,
build_skill_information, load_skill_content,
};
let mut skill_blocks: Vec<String> = Vec::new();
for sk in parsed_skills {
// Find the SkillInfo by path (more reliable than by name for
// qualified skills).
let Some(info) = slash_skills.iter().find(|s| s.path == sk.skill_path) else {
continue;
};
match load_skill_content(info).await {
Ok(mut content) => {
if cap_skill_body(&mut content) {
tracing::info!(
skill = %sk.name,
path = %info.path,
"skill body truncated at read cap"
);
}
let skill_dir = std::path::Path::new(&info.path)
.parent()
.and_then(|p| p.to_str());
let args = if sk.args.is_empty() {
None
} else {
Some(sk.args.as_str())
};
apply_substitutions(
&mut content,
args,
&SubstitutionContext {
skill_dir,
session_id: Some(session_id),
plugin_root: info.plugin_root.as_deref(),
plugin_data: info.plugin_data.as_deref(),
},
);
skill_blocks.push(build_skill_block(&sk.name, &sk.args, &content));
}
Err(e) => {
tracing::warn!(skill = %sk.name, error = %e, "failed to load skill for expansion");
}
}
}
if skill_blocks.is_empty() {
return None;
}
let refs: Vec<SkillRef<'_>> = parsed_skills
.iter()
.map(|sk| SkillRef {
name: &sk.name,
path: &sk.skill_path,
})
.collect();
Some(build_skill_information(&skill_blocks, &refs))
}
/// Resolve prompt blocks as a slash command.
/// `Ok(blocks)` = not a command, pass through. `Err(outcome)` = matched.
pub(super) fn resolve(
prompt_blocks: Vec<acp::ContentBlock>,
skills: &[SkillInfo],
availability: CommandAvailability,
_skill_rewrite: SkillSlashRewrite,
) -> Result<Vec<acp::ContentBlock>, SlashCommandOutcome> {
let Some((command_name, args)) = parse_slash_prefix(&prompt_blocks) else {
return Ok(prompt_blocks);
};
// Prompt-only commands (e.g. /loop) need a full agent round-trip, not
// a direct BuiltinAction. They're filtered against the same gate the
// PROMPT_COMMANDS entry declares -- looking it up here means the gate
// value lives in exactly one place (the PROMPT_COMMANDS entry) and a
// future addition just needs the entry, not a parallel branch.
if let Some(prompt_cmd) = PROMPT_COMMANDS.iter().find(|c| c.name == command_name)
&& availability.allows(prompt_cmd.gate)
{
// Dispatch by name so a future PROMPT_COMMANDS entry without a
// matching arm fails loudly at the call site instead of silently
// reusing /loop's prompt builder.
let mut blocks = match prompt_cmd.name {
"loop" => build_loop_prompt_blocks(args),
other => {
unreachable!("prompt-only command /{other} has no resolver wired in resolve()")
}
};
// Annotate with the compact invocation as `displayText` so every client
// and session replay renders "/loop <args>" instead of the expanded
// instruction. The pager does this client-side; bare-text clients rely
// on this server-side annotation.
let display_text = if args.is_empty() {
format!("/{command_name}")
} else {
format!("/{command_name} {args}")
};
if let Some(acp::ContentBlock::Text(tb)) = blocks.first_mut() {
let map = tb.meta.get_or_insert_with(acp::Meta::new);
map.insert(
"displayText".to_string(),
serde_json::Value::String(display_text),
);
}
// /loop is a prompt-only command — use InvokeSkill with empty skills
// so the caller forwards the rewritten blocks directly to the model.
return Err(SlashCommandOutcome::InvokeSkill {
blocks,
skills: vec![],
});
}
// Check if the leading /command is a builtin.
let commands = all_commands(skills, availability);
let builtin_match = commands
.iter()
.find(|c| matches!(c, SlashCommand::BuiltIn(b) if c.name() == command_name || b.aliases.contains(&command_name)));
if let Some(SlashCommand::BuiltIn(builtin)) = builtin_match {
let action = (builtin.resolve)(args);
return Err(SlashCommandOutcome::Builtin(action));
}
// Not a builtin — use the multi-skill parser to detect ALL /{skill}
// references in the full input text, splitting args at skill boundaries.
let full_text = prompt_blocks
.iter()
.find_map(|b| {
if let acp::ContentBlock::Text(t) = b {
Some(t.text.as_str())
} else {
None
}
})
.unwrap_or("");
if let Some(parsed_skills) = parse_skill_references(full_text, skills, availability) {
return Err(SlashCommandOutcome::InvokeSkill {
blocks: prompt_blocks,
skills: parsed_skills,
});
}
// No known skill matched — pass through as regular user input.
Ok(prompt_blocks)
}
/// Extract `(name, args)` if the first text block starts with `/`.
///
/// - `"/compact keep auth"` → `Some(("compact", "keep auth"))`
/// - `"please run /commit"` → `None` (not at start)
fn parse_slash_prefix(prompt_blocks: &[acp::ContentBlock]) -> Option<(&str, &str)> {
let text = prompt_blocks.iter().find_map(|b| {
if let acp::ContentBlock::Text(t) = b {
Some(t.text.as_str())
} else {
None
}
})?;
let trimmed = text.trim();
let without_slash = trimmed.strip_prefix('/')?;
let (name, args) = match without_slash.find(char::is_whitespace) {
Some(idx) => (&without_slash[..idx], without_slash[idx..].trim()),
None => (without_slash, ""),
};
if name.is_empty() {
return None;
}
Some((name, args))
}
/// Build the `/loop` prompt blocks for the shell client.
///
/// The wording (usage hint + scheduling instruction) is sourced from
/// `kigi-tools` so it stays identical to the pager's `LoopCommand` and the
/// two front-ends can't drift. Like the pager, there is no host-side interval
/// default: the model derives the cadence from the request and asks when none
/// is given.
fn build_loop_prompt_blocks(args: &str) -> Vec<acp::ContentBlock> {
use kigi_tools::implementations::kigi::{loop_schedule_instruction, loop_usage_message};
let text = if args.trim().is_empty() {
loop_usage_message().to_string()
} else {
loop_schedule_instruction(args)
};
vec![acp::ContentBlock::Text(acp::TextContent::new(text))]
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_tools::implementations::skills::types::SkillScope;
fn all_gated() -> CommandAvailability {
CommandAvailability::all_enabled()
}
fn text_block(s: &str) -> acp::ContentBlock {
acp::ContentBlock::Text(acp::TextContent::new(s.to_string()))
}
fn make_skill(name: &str, user_invocable: bool) -> SkillInfo {
SkillInfo {
name: name.to_string(),
display_name: None,
description: format!("A skill called {name}"),
when_to_use: None,
short_description: Some(format!("Short: {name}")),
author: None,
argument_hint: None,
path: format!("/path/to/{name}/SKILL.md"),
scope: SkillScope::Local,
config_source: None,
plugin_name: None,
plugin_version: None,
plugin_root: None,
plugin_data: None,
allowed_tools: None,
license: None,
compatibility: None,
metadata: None,
model: None,
effort: None,
user_invocable,
disable_model_invocation: false,
has_user_specified_description: false,
paths: None,
enabled: true,
body: None,
}
}
/// Extract the first parsed skill from an InvokeSkill outcome.
fn first_skill(outcome: SlashCommandOutcome) -> ParsedSkillRef {
match outcome {
SlashCommandOutcome::InvokeSkill { skills, .. } => {
assert!(!skills.is_empty(), "expected at least one skill");
skills.into_iter().next().unwrap()
}
_ => panic!("expected InvokeSkill"),
}
}
/// Extract original text from InvokeSkill blocks (for prompt-only commands like /loop).
fn invoke_text(outcome: SlashCommandOutcome) -> String {
match outcome {
SlashCommandOutcome::InvokeSkill { blocks, .. } => blocks
.iter()
.find_map(|b| match b {
acp::ContentBlock::Text(t) => Some(t.text.clone()),
_ => None,
})
.unwrap(),
_ => panic!("expected InvokeSkill"),
}
}
#[test]
fn skill_description_falls_back_when_no_short_description() {
let skill = SkillInfo {
short_description: None,
..make_skill("deploy", true)
};
let cmd = SlashCommand::Skill(&skill);
assert_eq!(cmd.description(), "A skill called deploy");
}
#[test]
fn parse_slash_prefix_extracts_name_and_args() {
assert_eq!(
parse_slash_prefix(&[text_block("/compact keep auth")]),
Some(("compact", "keep auth")),
);
assert_eq!(
parse_slash_prefix(&[text_block("/yolo")]),
Some(("yolo", "")),
);
}
#[test]
fn parse_slash_prefix_ignores_non_leading_slash() {
assert_eq!(
parse_slash_prefix(&[text_block("please run /commit")]),
None
);
assert_eq!(parse_slash_prefix(&[text_block("fix the bug")]), None);
assert_eq!(parse_slash_prefix(&[text_block("/")]), None);
}
#[test]
fn parse_slash_prefix_trims_whitespace() {
assert_eq!(
parse_slash_prefix(&[text_block(" /commit fix typo ")]),
Some(("commit", "fix typo")),
);
}
fn resolve_builtin(name: &str, args: &str) -> Option<BuiltinAction> {
BUILTIN_COMMANDS
.iter()
.chain(PROMPT_COMMANDS.iter())
.find(|b| b.name == name)
.map(|b| (b.resolve)(args))
}
#[test]
fn compact_parses_optional_context() {
assert!(matches!(
resolve_builtin("compact", ""),
Some(BuiltinAction::Compact { user_context: None })
));
assert!(matches!(
resolve_builtin("compact", "keep auth"),
Some(BuiltinAction::Compact { user_context: Some(ctx) }) if ctx == "keep auth"
));
}
#[test]
fn always_approve_parses_on_off() {
for arg in ["", "on", "true", "1", "yes", "enable"] {
assert!(
matches!(
resolve_builtin("always-approve", arg),
Some(BuiltinAction::SetYolo { enabled: true })
),
"expected on for {arg:?}",
);
}
for arg in ["off", "false", "0", "no", "disable"] {
assert!(
matches!(
resolve_builtin("always-approve", arg),
Some(BuiltinAction::SetYolo { enabled: false })
),
"expected off for {arg:?}",
);
}
}
#[test]
fn yolo_alias_resolves_to_always_approve() {
let blocks = vec![text_block("/yolo on")];
let outcome = resolve(blocks, &[], all_gated(), SkillSlashRewrite::default()).unwrap_err();
assert!(matches!(
outcome,
SlashCommandOutcome::Builtin(BuiltinAction::SetYolo { enabled: true })
));
}
#[test]
fn resolve_routes_builtin() {
let outcome = resolve(
vec![text_block("/compact preserve auth")],
&[],
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
assert!(matches!(
outcome,
SlashCommandOutcome::Builtin(BuiltinAction::Compact { user_context: Some(ctx) })
if ctx == "preserve auth"
));
}
#[test]
fn status_alias_resolves_to_session_info() {
let outcome = resolve(
vec![text_block("/status")],
&[],
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
assert!(matches!(
outcome,
SlashCommandOutcome::Builtin(BuiltinAction::SessionInfo)
));
}
#[test]
fn resolve_parses_skill_with_args() {
let skills = vec![make_skill("commit", true)];
let outcome = resolve(
vec![text_block("/commit fix typo")],
&skills,
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
let skill = first_skill(outcome);
assert_eq!(skill.name, "commit");
assert_eq!(skill.args, "fix typo");
let outcome = resolve(
vec![text_block("/commit")],
&skills,
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
let skill = first_skill(outcome);
assert_eq!(skill.name, "commit");
assert_eq!(skill.args, "");
}
/// `build_skill_information_for_refs` loads the SKILL.md, applies
/// substitutions, and wraps everything in `<skill_information>`;
/// unloadable refs are skipped, and no loadable content → `None`.
/// Shared by turn start and the interjection drain.
#[tokio::test]
async fn build_skill_information_for_refs_loads_and_wraps() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("SKILL.md");
std::fs::write(&path, "Body with $ARGUMENTS").unwrap();
let mut skill = make_skill("commit", true);
skill.path = path.to_string_lossy().to_string();
let skills = vec![skill];
let parsed = parse_skill_references("/commit fix typo", &skills, all_gated())
.expect("known skill must parse");
let info = build_skill_information_for_refs(&parsed, &skills, "sid-1")
.await
.expect("skill body must load");
assert!(info.starts_with("<skill_information>"), "got: {info}");
assert!(
info.contains("<skill name=\"commit\" args=\"fix typo\">"),
"got: {info}"
);
assert!(
info.contains("Body with fix typo"),
"$ARGUMENTS must substitute: {info}"
);
// Missing file → logged, skipped, and with nothing loaded: None.
let missing = vec![make_skill("ghost", true)];
let parsed = parse_skill_references("/ghost", &missing, all_gated())
.expect("known skill must parse");
assert_eq!(
build_skill_information_for_refs(&parsed, &missing, "sid-1").await,
None
);
}
/// A 220 KB skill body stops at the read cap; the tail stays on disk.
#[tokio::test]
async fn build_skill_information_for_refs_caps_an_oversized_body() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("SKILL.md");
let body = (1..=1100)
.map(|n| format!("{n:05} {}", "x".repeat(194)))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&path, &body).unwrap();
let mut skill = make_skill("big", true);
skill.path = path.to_string_lossy().to_string();
let skills = vec![skill];
let parsed =
parse_skill_references("/big", &skills, all_gated()).expect("known skill must parse");
let info = build_skill_information_for_refs(&parsed, &skills, "sid-1")
.await
.expect("skill body must load");
assert!(info.contains("00001 "), "head must survive");
assert!(!info.contains("01100 "), "tail must be cut");
assert!(info.contains("Skill body truncated at the 25000-token cap"));
}
#[test]
fn resolve_loop_annotates_block_with_compact_display_text() {
let outcome = resolve(
vec![text_block("/loop 1m echo hello")],
&[],
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
let blocks = match outcome {
SlashCommandOutcome::InvokeSkill { blocks, skills } => {
assert!(skills.is_empty(), "/loop is a prompt-only command");
blocks
}
_ => panic!("expected InvokeSkill for /loop"),
};
let acp::ContentBlock::Text(tb) = blocks.first().expect("one block") else {
panic!("expected a text block");
};
assert!(
tb.text.len() > "/loop 1m echo hello".len(),
"wire text should be the expanded instruction"
);
let display = tb
.meta
.as_ref()
.and_then(|m| m.get("displayText"))
.and_then(|v| v.as_str());
assert_eq!(display, Some("/loop 1m echo hello"));
assert!(
tb.meta
.as_ref()
.and_then(|m| m.get("displayAsSkill"))
.is_none(),
"/loop renders as a plain prompt, not a skill"
);
}
#[test]
fn resolve_loop_without_args_uses_bare_command_display_text() {
// `/loop` with no args expands to the usage message but should still
// carry a sensible compact `displayText`.
let outcome = resolve(
vec![text_block("/loop")],
&[],
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
let SlashCommandOutcome::InvokeSkill { blocks, .. } = outcome else {
panic!("expected InvokeSkill for /loop");
};
let acp::ContentBlock::Text(tb) = blocks.first().expect("one block") else {
panic!("expected a text block");
};
assert_eq!(
tb.meta
.as_ref()
.and_then(|m| m.get("displayText"))
.and_then(|v| v.as_str()),
Some("/loop")
);
}
#[test]
fn resolve_passthrough_preserves_original_blocks() {
// External-harness agents: blocks are passed through verbatim.
// The prompt assembly layer decides how to format them.
let skills = vec![make_skill("commit", true)];
let outcome = resolve(
vec![text_block("/commit fix typo")],
&skills,
all_gated(),
SkillSlashRewrite::Passthrough,
)
.unwrap_err();
assert_eq!(invoke_text(outcome), "/commit fix typo");
let outcome = resolve(
vec![text_block("/commit")],
&skills,
all_gated(),
SkillSlashRewrite::Passthrough,
)
.unwrap_err();
assert_eq!(invoke_text(outcome), "/commit");
}
#[test]
fn resolve_passes_through_normal_prompts() {
let skills = vec![make_skill("commit", true)];
assert!(
resolve(
vec![text_block("fix the login bug")],
&skills,
all_gated(),
SkillSlashRewrite::default()
)
.is_ok()
);
assert!(
resolve(
vec![text_block("/unknown")],
&skills,
all_gated(),
SkillSlashRewrite::default()
)
.is_ok()
);
}
#[test]
fn resolve_filters_non_invocable_skills() {
let skills = vec![make_skill("internal-only", false)];
assert!(
resolve(
vec![text_block("/internal-only")],
&skills,
all_gated(),
SkillSlashRewrite::default()
)
.is_ok()
);
}
#[test]
fn resolve_builtin_shadows_same_named_skill() {
let skills = vec![make_skill("compact", true)];
let outcome = resolve(
vec![text_block("/compact")],
&skills,
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
assert!(matches!(outcome, SlashCommandOutcome::Builtin(_)));
}
#[test]
fn available_commands_orders_builtins_first() {
let skills = vec![make_skill("commit", true), make_skill("deploy", true)];
let commands = available_commands(&skills, all_gated());
let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
names,
[
"compact",
"always-approve",
"flush",
"dream",
"memory",
"context",
"hooks-trust",
"hooks-list",
"hooks-add",
"hooks-remove",
"hooks-untrust",
"plugins",
"reload-plugins",
"session-info",
"feedback",
"goal",
"graph",
"swarm",
"loop",
"commit",
"deploy",
]
);
}
fn advertised_names(availability: CommandAvailability) -> Vec<String> {
available_commands(&[], availability)
.into_iter()
.map(|c| c.name)
.collect()
}
#[test]
fn availability_filters_memory_commands() {
// memory=false hides /flush and /dream but NOT /memory (gated on
// memory_configured instead, so the user can re-enable via toggle).
let names = advertised_names(CommandAvailability {
memory: false,
..CommandAvailability::all_enabled()
});
assert!(!names.iter().any(|n| n == "flush"), "got: {names:?}");
assert!(!names.iter().any(|n| n == "dream"), "got: {names:?}");
assert!(
names.iter().any(|n| n == "memory"),
"/memory should still be available when memory_configured=true, got: {names:?}"
);
assert!(names.iter().any(|n| n == "compact"));
// memory_configured=false hides /memory too.
let names2 = advertised_names(CommandAvailability {
memory: false,
memory_configured: false,
..CommandAvailability::all_enabled()
});
assert!(
!names2.iter().any(|n| n == "memory"),
"/memory should be hidden when memory_configured=false, got: {names2:?}"
);
}
#[test]
fn availability_filters_loop_command() {
let names = advertised_names(CommandAvailability {
scheduler: false,
..CommandAvailability::all_enabled()
});
assert!(!names.iter().any(|n| n == "loop"), "got: {names:?}");
}
#[test]
fn availability_filters_hooks_and_plugins() {
let names = advertised_names(CommandAvailability {
hooks: false,
plugins: false,
..CommandAvailability::all_enabled()
});
for n in [
"hooks-trust",
"hooks-list",
"hooks-add",
"hooks-remove",
"hooks-untrust",
"plugins",
"reload-plugins",
] {
assert!(
!names.iter().any(|x| x == n),
"{n} should be hidden, got: {names:?}",
);
}
}
#[test]
fn availability_filters_goal_command() {
let names = advertised_names(CommandAvailability {
goal: false,
..CommandAvailability::all_enabled()
});
assert!(!names.iter().any(|n| n == "goal"), "got: {names:?}");
}
#[test]
fn availability_filters_graph_command() {
let names = advertised_names(CommandAvailability {
graph: false,
..CommandAvailability::all_enabled()
});
assert!(!names.iter().any(|n| n == "graph"), "got: {names:?}");
}
#[test]
fn graph_does_not_resolve_when_gate_off() {
let availability = CommandAvailability {
graph: false,
..CommandAvailability::all_enabled()
};
assert!(
resolve(
vec![text_block("/graph status")],
&[],
availability,
SkillSlashRewrite::default(),
)
.is_ok(),
"expected pass-through (Ok), got an outcome",
);
}
/// 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")
.expect("/graph must resolve");
match set {
BuiltinAction::GraphSet {
objective,
token_budget,
} => {
assert_eq!(objective, "ship the feature");
assert_eq!(token_budget, Some(5000));
}
other => panic!("expected GraphSet, got /{}", other.command_name()),
}
assert!(matches!(
resolve_builtin("graph", ""),
Some(BuiltinAction::GraphStatus)
));
assert!(matches!(
resolve_builtin("graph", "show"),
Some(BuiltinAction::GraphShow)
));
assert!(matches!(
resolve_builtin("graph", "pause"),
Some(BuiltinAction::GraphPause)
));
assert!(matches!(
resolve_builtin("graph", "resume"),
Some(BuiltinAction::GraphResume { extra_budget: None })
));
assert!(matches!(
resolve_builtin("graph", "resume --budget 800"),
Some(BuiltinAction::GraphResume {
extra_budget: Some(800)
})
));
// Malformed top-ups resolve to a PLAIN resume — never to
// GraphSet, which would silently replace a resumable graph; the
// BudgetLimited resume arm then prints the usage hint.
assert!(matches!(
resolve_builtin("graph", "resume --budget nope"),
Some(BuiltinAction::GraphResume { extra_budget: None })
));
assert!(matches!(
resolve_builtin("graph", "Resume --Budget 800"),
Some(BuiltinAction::GraphResume {
extra_budget: Some(800)
})
));
assert!(matches!(
resolve_builtin("graph", "clear"),
Some(BuiltinAction::GraphClear)
));
}
#[test]
fn goal_does_not_resolve_when_update_goal_unavailable() {
let availability = CommandAvailability {
goal: false,
..CommandAvailability::all_enabled()
};
assert!(
resolve(
vec![text_block("/goal status")],
&[],
availability,
SkillSlashRewrite::default(),
)
.is_ok(),
"expected pass-through (Ok), got an outcome",
);
}
#[test]
fn loop_does_not_resolve_when_scheduler_unavailable() {
// Without the scheduler gate the shell should not route /loop --
// it would otherwise produce a useless "call scheduler_create"
// prompt the model can't act on.
let availability = CommandAvailability {
scheduler: false,
..CommandAvailability::all_enabled()
};
assert!(
resolve(
vec![text_block("/loop 5m do thing")],
&[],
availability,
SkillSlashRewrite::default(),
)
.is_ok(),
"expected pass-through (Ok), got an outcome",
);
}
/// Extract the text of the first block produced by `build_loop_prompt_blocks`.
fn loop_text(args: &str) -> String {
match build_loop_prompt_blocks(args).into_iter().next() {
Some(acp::ContentBlock::Text(t)) => t.text,
other => panic!("expected a text block, got {other:?}"),
}
}
#[test]
fn loop_usage_has_no_10m_default() {
// The shell client must not advertise a silent 10m default.
let usage = loop_text("");
assert!(usage.contains("Usage: /loop"), "got: {usage}");
assert!(
!usage.contains("10m"),
"usage must not claim a default: {usage}"
);
}
#[test]
fn loop_instruction_derives_interval_without_default_or_inline_execute() {
let instr = loop_text("every 30 minutes do x");
assert!(
!instr.contains("10m"),
"instruction must not default: {instr}"
);
assert!(instr.contains("30 minutes"));
assert!(instr.contains("<number><unit>"));
assert!(instr.contains("ask the user how often"));
assert!(instr.contains("Do NOT execute the prompt inline"));
assert!(
!instr.contains("immediately execute the parsed prompt"),
"stale inline-execute wording must be gone: {instr}"
);
assert!(instr.contains("every 30 minutes do x"));
}
#[test]
fn loop_prompt_matches_pager_wording() {
// The shell and pager must stay textually identical so they don't drift.
use kigi_tools::implementations::kigi::{loop_schedule_instruction, loop_usage_message};
assert_eq!(loop_text(""), loop_usage_message());
assert_eq!(
loop_text("2h run tests"),
loop_schedule_instruction("2h run tests")
);
}
#[test]
fn build_tools_meta_serialises_tool_names() {
let names = vec!["scheduler_create".to_string(), "web_search".to_string()];
let v = build_tools_meta(&names);
assert_eq!(
serde_json::Value::Object(v),
serde_json::json!({"tools": ["scheduler_create", "web_search"]})
);
}
#[test]
fn pre_session_builtin_commands_excludes_gated_entries() {
// The pre-session list (advertised in InitializeResponse._meta)
// with a default (fail-closed) availability must not include any
// gated command -- we don't know the toolset yet at that point.
let names: Vec<String> = builtin_commands(CommandAvailability::default())
.into_iter()
.map(|c| c.name)
.collect();
for forbidden in [
"flush",
"dream",
"memory",
"feedback",
"goal",
"graph",
"hooks-list",
"plugins",
"reload-plugins",
] {
assert!(
!names.iter().any(|n| n == forbidden),
"{forbidden} should be excluded pre-session, got: {names:?}",
);
}
// Always-on commands are still present.
for required in ["compact", "always-approve", "context", "session-info"] {
assert!(
names.iter().any(|n| n == required),
"{required} should be present, got: {names:?}",
);
}
}
#[test]
fn pre_session_builtin_commands_advertises_goal_when_flag_enabled() {
// `/goal` is gated on a config feature flag known at initialize
// time (not a live toolset), so when the pre-session availability
// enables it the command must be advertised -- otherwise it would
// only show up after the first user turn created a session.
let availability = CommandAvailability {
goal: true,
..CommandAvailability::default()
};
let names: Vec<String> = builtin_commands(availability)
.into_iter()
.map(|c| c.name)
.collect();
assert!(
names.iter().any(|n| n == "goal"),
"goal should be advertised pre-session when the flag is on, got: {names:?}",
);
// Runtime/tool-dependent gates stay closed pre-session.
for forbidden in ["flush", "dream", "memory", "feedback", "plugins"] {
assert!(
!names.iter().any(|n| n == forbidden),
"{forbidden} should stay excluded pre-session, got: {names:?}",
);
}
}
#[test]
fn available_commands_populates_acp_fields() {
let skills = vec![make_skill("commit", true)];
let commands = available_commands(&skills, all_gated());
let builtin = commands.iter().find(|c| c.name == "compact").unwrap();
assert!(builtin.input.is_some());
let flush = commands.iter().find(|c| c.name == "flush").unwrap();
// no argument_hint
assert!(flush.input.is_none());
let skill = commands.iter().find(|c| c.name == "commit").unwrap();
assert_eq!(skill.description, "Short: commit");
}
#[test]
fn flush_resolves_to_builtin_action() {
assert!(matches!(
resolve_builtin("flush", ""),
Some(BuiltinAction::FlushMemory)
));
// Args are ignored — still resolves to FlushMemory
assert!(matches!(
resolve_builtin("flush", "some extra args"),
Some(BuiltinAction::FlushMemory)
));
}
#[test]
fn resolve_routes_flush_builtin() {
let outcome = resolve(
vec![text_block("/flush")],
&[],
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
assert!(matches!(
outcome,
SlashCommandOutcome::Builtin(BuiltinAction::FlushMemory)
));
}
#[test]
fn flush_builtin_shadows_same_named_skill() {
let skills = vec![make_skill("flush", true)];
let outcome = resolve(
vec![text_block("/flush")],
&skills,
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
assert!(matches!(outcome, SlashCommandOutcome::Builtin(_)));
}
#[test]
fn dream_resolves_to_builtin_action() {
assert!(matches!(
resolve_builtin("dream", ""),
Some(BuiltinAction::Dream)
));
assert!(matches!(
resolve_builtin("dream", "extra args"),
Some(BuiltinAction::Dream)
));
}
#[test]
fn resolve_routes_dream_builtin() {
let outcome = resolve(
vec![text_block("/dream")],
&[],
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
assert!(matches!(
outcome,
SlashCommandOutcome::Builtin(BuiltinAction::Dream)
));
}
#[test]
fn dream_builtin_shadows_same_named_skill() {
let skills = vec![make_skill("dream", true)];
let outcome = resolve(
vec![text_block("/dream")],
&skills,
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
assert!(matches!(outcome, SlashCommandOutcome::Builtin(_)));
}
fn make_scoped_skill(name: &str, scope: SkillScope) -> SkillInfo {
SkillInfo {
name: name.to_string(),
display_name: None,
description: format!("A {scope:?} skill called {name}"),
when_to_use: None,
short_description: Some(format!("Short: {name}")),
author: None,
argument_hint: None,
path: format!("/path/to/{name}/{scope:?}/SKILL.md"),
scope,
config_source: None,
plugin_name: None,
plugin_version: None,
plugin_root: None,
plugin_data: None,
allowed_tools: None,
license: None,
compatibility: None,
metadata: None,
model: None,
effort: None,
user_invocable: true,
disable_model_invocation: false,
has_user_specified_description: false,
paths: None,
enabled: true,
body: None,
}
}
#[test]
fn resolve_ambiguous_bare_name_passes_through() {
// Two skills share the bare name "commit" in different scopes.
let skills = vec![
make_scoped_skill("commit", SkillScope::Local),
make_scoped_skill("commit", SkillScope::User),
];
// Bare "/commit" is ambiguous -- should pass through (not first-match).
assert!(
resolve(
vec![text_block("/commit")],
&skills,
all_gated(),
SkillSlashRewrite::default()
)
.is_ok()
);
}
#[test]
fn resolve_qualified_skill_name() {
let skills = vec![
make_scoped_skill("commit", SkillScope::Local),
make_scoped_skill("commit", SkillScope::User),
];
// Qualified "/local:commit" resolves unambiguously.
let outcome = resolve(
vec![text_block("/local:commit fix typo")],
&skills,
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
let skill = first_skill(outcome);
assert_eq!(skill.name, "local:commit");
assert_eq!(skill.args, "fix typo");
// Qualified "/user:commit" resolves unambiguously.
let outcome = resolve(
vec![text_block("/user:commit")],
&skills,
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
let skill = first_skill(outcome);
assert_eq!(skill.name, "user:commit");
assert_eq!(skill.args, "");
}
#[test]
fn available_commands_uses_qualified_names_for_duplicates() {
let skills = vec![
make_scoped_skill("commit", SkillScope::Local),
make_scoped_skill("commit", SkillScope::User),
make_skill("deploy", true),
];
let commands = available_commands(&skills, all_gated());
let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
// Duplicate "commit" skills should use qualified names.
assert!(names.contains(&"local:commit"));
assert!(names.contains(&"user:commit"));
// Unique "deploy" keeps bare name only (no duplicate qualified form).
assert!(names.contains(&"deploy"));
assert!(
!names.contains(&"local:deploy"),
"non-colliding skill should NOT get a qualified duplicate, got: {names:?}"
);
// Bare "commit" should NOT appear.
assert!(!names.contains(&"commit"));
}
#[test]
fn available_commands_qualifies_builtin_colliding_skill() {
// A skill named "compact" collides with the builtin /compact.
let skills = vec![
make_scoped_skill("compact", SkillScope::Local),
make_skill("deploy", true),
];
let commands = available_commands(&skills, all_gated());
let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
// The skill should be advertised under its qualified name.
assert!(
names.contains(&"local:compact"),
"builtin-colliding skill should use qualified name, got: {names:?}"
);
// The bare "compact" entry should be the builtin, not the skill.
let compact_cmd = commands.iter().find(|c| c.name == "compact").unwrap();
assert!(
compact_cmd.meta.is_none(),
"bare 'compact' should be the builtin (no meta)"
);
// Non-colliding skill keeps bare name.
assert!(names.contains(&"deploy"));
}
#[test]
fn resolve_qualified_builtin_colliding_skill() {
// A skill named "compact" collides with the builtin.
let skills = vec![make_scoped_skill("compact", SkillScope::Local)];
// Bare "/compact" should resolve to the builtin, not the skill.
let outcome = resolve(
vec![text_block("/compact")],
&skills,
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
assert!(matches!(outcome, SlashCommandOutcome::Builtin(_)));
// Qualified "/local:compact" should resolve to the skill.
let outcome = resolve(
vec![text_block("/local:compact")],
&skills,
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
let skill = first_skill(outcome);
assert_eq!(skill.name, "local:compact");
assert_eq!(skill.args, "");
}
#[test]
fn feedback_does_not_resolve_when_disabled() {
// /feedback should pass through as unrecognized when the feature is off.
assert!(
resolve(
vec![text_block("/feedback hello")],
&[],
CommandAvailability::default(),
SkillSlashRewrite::default()
)
.is_ok()
);
}
#[test]
fn feedback_resolves_when_enabled() {
let outcome = resolve(
vec![text_block("/feedback hello")],
&[],
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
assert!(matches!(
outcome,
SlashCommandOutcome::Builtin(BuiltinAction::Feedback { ref text }) if text == "hello"
));
}
fn advertised_names_with(availability: CommandAvailability) -> Vec<String> {
available_commands(&[], availability)
.into_iter()
.map(|c| c.name)
.collect()
}
/// `CommandAvailability::default()` must be fail-closed: every gated
/// command is hidden, only `BuiltinGate::AlwaysOn` survives. The
/// pre-session `MvpAgent::command_availability()` builds on this value
/// (only flipping config-derived gates like `goal` on), so a
/// regression here would re-expose `/flush`, `/loop`, etc. on the home
/// screen for harnesses that won't actually run them.
#[test]
fn default_availability_is_fail_closed_on_every_gate() {
let names = advertised_names_with(CommandAvailability::default());
for forbidden in [
"flush",
"dream",
"feedback",
"goal",
"graph",
"loop",
"hooks-list",
"hooks-trust",
"hooks-untrust",
"hooks-add",
"hooks-remove",
"plugins",
"reload-plugins",
] {
assert!(
!names.iter().any(|n| n == forbidden),
"{forbidden} must not be advertised under default fail-closed availability, got: {names:?}",
);
}
for required in ["compact", "always-approve", "context", "session-info"] {
assert!(
names.iter().any(|n| n == required),
"AlwaysOn {required} must always be advertised, got: {names:?}",
);
}
}
/// `/flush` is a memory-write that's only useful when the model can
/// later read back what it wrote. The shell's
/// `build_command_availability()` ANDs `memory.is_enabled()` with
/// `memory_search`/`memory_get` registration; the gate itself just
/// reads `availability.memory`. Lock both halves so a future change
/// to either side is forced through this test.
#[test]
fn flush_hidden_when_memory_gate_off_visible_when_on() {
let off = advertised_names_with(CommandAvailability::default());
assert!(!off.iter().any(|n| n == "flush"), "got: {off:?}");
assert!(!off.iter().any(|n| n == "dream"), "got: {off:?}");
// /memory is gated on memory_configured, not memory — hidden here
// because Default sets both to false.
assert!(!off.iter().any(|n| n == "memory"), "got: {off:?}");
let on = advertised_names_with(CommandAvailability {
memory: true,
memory_configured: true,
..CommandAvailability::default()
});
assert!(on.iter().any(|n| n == "flush"), "got: {on:?}");
assert!(on.iter().any(|n| n == "dream"), "got: {on:?}");
assert!(on.iter().any(|n| n == "memory"), "got: {on:?}");
}
#[test]
fn memory_bare_resolves_to_browse() {
assert!(matches!(
resolve_builtin("memory", ""),
Some(BuiltinAction::MemoryBrowse)
));
// Any unrecognized arg also falls through to browse
assert!(matches!(
resolve_builtin("memory", "status"),
Some(BuiltinAction::MemoryBrowse)
));
}
#[test]
fn memory_on_off_resolves_to_toggle() {
for (arg, expected) in [
("on", true),
("enable", true),
("ON", true),
("Enable", true),
("off", false),
("disable", false),
("OFF", false),
("Disable", false),
] {
assert!(
matches!(
resolve_builtin("memory", arg),
Some(BuiltinAction::MemoryToggle { enabled }) if enabled == expected
),
"expected toggle({expected}) for {arg:?}",
);
}
}
#[test]
fn mem_alias_resolves_to_memory_browse() {
let outcome = resolve(
vec![text_block("/mem")],
&[],
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
assert!(matches!(
outcome,
SlashCommandOutcome::Builtin(BuiltinAction::MemoryBrowse)
));
}
#[test]
fn mem_alias_resolves_toggle_with_args() {
let outcome = resolve(
vec![text_block("/mem off")],
&[],
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
assert!(matches!(
outcome,
SlashCommandOutcome::Builtin(BuiltinAction::MemoryToggle { enabled: false })
));
}
#[test]
fn memory_resolves_when_disabled_but_configured() {
// memory=false but memory_configured=true: /memory must still work
// so the user can re-enable via the toggle.
let availability = CommandAvailability {
memory: false,
..CommandAvailability::all_enabled()
};
let outcome = resolve(
vec![text_block("/memory")],
&[],
availability,
SkillSlashRewrite::default(),
);
assert!(
outcome.is_err(),
"expected /memory to resolve when memory_configured=true",
);
}
#[test]
fn memory_not_resolved_when_not_configured() {
let availability = CommandAvailability {
memory: false,
memory_configured: false,
..CommandAvailability::all_enabled()
};
assert!(
resolve(
vec![text_block("/memory")],
&[],
availability,
SkillSlashRewrite::default(),
)
.is_ok(),
"expected pass-through (Ok) when memory_configured is false",
);
}
#[test]
fn parse_skill_refs_single_skill() {
let skills = vec![make_skill("commit", true)];
let refs = parse_skill_references("/commit fix typo", &skills, all_gated()).unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].name, "commit");
assert_eq!(refs[0].args, "fix typo");
}
#[test]
fn parse_skill_refs_single_no_args() {
let skills = vec![make_skill("commit", true)];
let refs = parse_skill_references("/commit", &skills, all_gated()).unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].name, "commit");
assert_eq!(refs[0].args, "");
}
#[test]
fn parse_skill_refs_multi_skill() {
let skills = vec![make_skill("review", true), make_skill("lint", true)];
let refs = parse_skill_references("/review fix auth /lint --strict", &skills, all_gated())
.unwrap();
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].name, "review");
assert_eq!(refs[0].args, "fix auth");
assert_eq!(refs[1].name, "lint");
assert_eq!(refs[1].args, "--strict");
}
#[test]
fn parse_skill_refs_ignores_unknown_slash() {
let skills = vec![make_skill("commit", true)];
// /api/v2/users is not a known skill — should be ignored.
let result = parse_skill_references("check /api/v2/users", &skills, all_gated());
assert!(result.is_none());
}
#[test]
fn parse_skill_refs_ignores_builtins() {
let skills = vec![make_skill("commit", true)];
// /compact is a builtin — should NOT appear in skill refs.
let result = parse_skill_references("/compact", &skills, all_gated());
assert!(result.is_none());
}
#[test]
fn parse_skill_refs_empty_text() {
let skills = vec![make_skill("commit", true)];
assert!(parse_skill_references("", &skills, all_gated()).is_none());
}
#[test]
fn parse_skill_refs_no_slash() {
let skills = vec![make_skill("commit", true)];
assert!(parse_skill_references("just some text", &skills, all_gated()).is_none());
}
#[test]
fn parse_skill_refs_non_invocable_skill_ignored() {
let skills = vec![make_skill("internal-only", false)];
assert!(parse_skill_references("/internal-only", &skills, all_gated()).is_none());
}
#[test]
fn parse_skill_refs_qualified_name() {
let skills = vec![
make_scoped_skill("commit", SkillScope::Local),
make_scoped_skill("commit", SkillScope::User),
];
let refs = parse_skill_references("/local:commit fix typo", &skills, all_gated()).unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].name, "local:commit");
assert_eq!(refs[0].args, "fix typo");
assert_eq!(refs[0].qualified_name, "local:commit");
}
#[test]
fn parse_skill_refs_text_before_first_skill() {
// Text before the first skill reference is part of user query,
// not consumed as args.
let skills = vec![make_skill("commit", true)];
let refs =
parse_skill_references("please do /commit fix typo", &skills, all_gated()).unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].name, "commit");
assert_eq!(refs[0].args, "fix typo");
}
fn resolve_goal(args: &str) -> BuiltinAction {
let blocks = vec![text_block(&format!("/goal {args}"))];
match resolve(blocks, &[], all_gated(), SkillSlashRewrite::default()).unwrap_err() {
SlashCommandOutcome::Builtin(action) => action,
_ => panic!("expected Builtin outcome"),
}
}
#[test]
fn goal_empty_resolves_to_status() {
assert!(matches!(resolve_goal(""), BuiltinAction::GoalStatus));
}
#[test]
fn goal_status_keyword_resolves_to_status() {
assert!(matches!(resolve_goal("status"), BuiltinAction::GoalStatus));
assert!(matches!(resolve_goal("STATUS"), BuiltinAction::GoalStatus));
}
#[test]
fn goal_pause_resolves_to_pause() {
assert!(matches!(resolve_goal("pause"), BuiltinAction::GoalPause));
assert!(matches!(resolve_goal("PAUSE"), BuiltinAction::GoalPause));
}
#[test]
fn goal_resume_resolves_to_resume() {
assert!(matches!(resolve_goal("resume"), BuiltinAction::GoalResume));
}
#[test]
fn goal_clear_resolves_to_clear() {
assert!(matches!(resolve_goal("clear"), BuiltinAction::GoalClear));
}
#[test]
fn goal_objective_resolves_to_set() {
match resolve_goal("implement auth module") {
BuiltinAction::GoalSet {
objective,
token_budget,
} => {
assert_eq!(objective, "implement auth module");
assert_eq!(token_budget, None);
}
other => panic!("expected GoalSet, got {}", other.command_name()),
}
}
#[test]
fn goal_set_preserves_original_casing() {
match resolve_goal("Fix BUG in AuthManager") {
BuiltinAction::GoalSet { objective, .. } => {
assert_eq!(objective, "Fix BUG in AuthManager");
}
other => panic!("expected GoalSet, got {}", other.command_name()),
}
}
#[test]
fn goal_set_trailing_budget_flag_parses() {
match resolve_goal("implement X --budget 500000") {
BuiltinAction::GoalSet {
objective,
token_budget,
} => {
assert_eq!(objective, "implement X");
assert_eq!(token_budget, Some(500_000));
}
other => panic!("expected GoalSet, got {}", other.command_name()),
}
}
#[test]
fn goal_set_budget_accepts_boundary_and_extra_whitespace() {
for (text, objective, budget) in [
("do x --budget 1", "do x", 1),
("do x --budget 77", "do x", 77),
("do x \t --budget 500000", "do x", 500_000),
] {
match resolve_goal(text) {
BuiltinAction::GoalSet {
objective: o,
token_budget,
} => {
assert_eq!(o, objective);
assert_eq!(token_budget, Some(budget), "for {text:?}");
}
other => panic!("expected GoalSet, got {}", other.command_name()),
}
}
}
#[test]
fn goal_set_malformed_budget_stays_in_objective() {
// Non-numeric, missing, non-positive, glued, signed, overflowing,
// or mid-text values must not be consumed as a budget.
for text in [
"implement X --budget abc",
"implement X --budget",
"implement X --budget 0",
"implement X --budget -5",
"implement X --budget +5",
"implement X --budget 99999999999999999999",
"implement X --budget5",
"implement X --budget500000",
"tune my-fund--budget 100",
"fix the --budget flag parsing bug",
"--budget 500000",
] {
match resolve_goal(text) {
BuiltinAction::GoalSet {
objective,
token_budget,
} => {
assert_eq!(objective, text, "objective must be preserved verbatim");
assert_eq!(token_budget, None, "no budget must be parsed from {text:?}");
}
other => panic!("expected GoalSet, got {}", other.command_name()),
}
}
}
#[test]
fn goal_command_name_is_goal() {
assert_eq!(BuiltinAction::GoalStatus.command_name(), "goal");
assert_eq!(BuiltinAction::GoalPause.command_name(), "goal");
assert_eq!(BuiltinAction::GoalResume.command_name(), "goal");
assert_eq!(BuiltinAction::GoalClear.command_name(), "goal");
assert_eq!(
BuiltinAction::GoalSet {
objective: "x".into(),
token_budget: None,
}
.command_name(),
"goal"
);
}
#[test]
fn goal_args_provided() {
assert!(
BuiltinAction::GoalSet {
objective: "x".into(),
token_budget: None,
}
.args_provided()
);
assert!(!BuiltinAction::GoalStatus.args_provided());
assert!(!BuiltinAction::GoalPause.args_provided());
assert!(!BuiltinAction::GoalResume.args_provided());
assert!(!BuiltinAction::GoalClear.args_provided());
}
// These test the exact tracker state transitions that the slash
// command handlers perform, without constructing a full SessionActor.
#[test]
fn goal_tracker_status_with_no_goal_returns_none() {
use crate::session::goal_tracker::GoalTracker;
let tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
assert!(tracker.snapshot().is_none());
assert!(tracker.status().is_none());
}
#[test]
fn goal_tracker_create_sets_active() {
use crate::session::goal_tracker::{GoalStatus, GoalTracker};
let mut tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
tracker.create_goal("g1".into(), "obj".into(), None, 0, "now".into(), None);
assert_eq!(tracker.status(), Some(GoalStatus::Active));
assert_eq!(tracker.objective(), Some("obj"));
}
#[test]
fn goal_tracker_pause_only_when_active() {
use crate::session::goal_tracker::{GoalPauseReason, GoalStatus, GoalTracker};
let mut tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
// No goal — pause returns false
assert!(!tracker.pause(GoalPauseReason::User));
tracker.create_goal("g1".into(), "obj".into(), None, 0, "now".into(), None);
assert!(tracker.pause(GoalPauseReason::User));
assert_eq!(tracker.status(), Some(GoalStatus::UserPaused));
// Already paused — pause returns false
assert!(!tracker.pause(GoalPauseReason::User));
}
#[test]
fn goal_tracker_resume_only_when_paused() {
use crate::session::goal_tracker::{GoalPauseReason, GoalStatus, GoalTracker};
let mut tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
tracker.create_goal("g1".into(), "obj".into(), None, 0, "now".into(), None);
// Active — resume returns false
assert!(!tracker.resume());
tracker.pause(GoalPauseReason::User);
assert!(tracker.resume());
assert_eq!(tracker.status(), Some(GoalStatus::Active));
}
#[test]
fn goal_tracker_clear_removes_orchestration() {
use crate::session::goal_tracker::GoalTracker;
let mut tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
tracker.create_goal("g1".into(), "obj".into(), None, 0, "now".into(), None);
assert!(tracker.snapshot().is_some());
tracker.clear();
assert!(tracker.snapshot().is_none());
}
#[test]
fn goal_tracker_create_replaces_existing() {
use crate::session::goal_tracker::GoalTracker;
let mut tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
tracker.create_goal("g1".into(), "first".into(), None, 0, "now".into(), None);
tracker.create_goal("g2".into(), "second".into(), None, 0, "now".into(), None);
assert_eq!(tracker.objective(), Some("second"));
}
#[test]
fn goal_tracker_account_elapsed_flushes_delta() {
use crate::session::goal_tracker::GoalTracker;
let mut tracker = GoalTracker::new(std::path::PathBuf::from("/tmp/test"));
tracker.create_goal("g1".into(), "obj".into(), None, 0, "now".into(), None);
// After create_goal, elapsed_ms is 0 but active_since is set.
let before = tracker.snapshot().unwrap().elapsed_ms;
assert_eq!(before, 0);
// account_elapsed flushes pending wall-clock time.
std::thread::sleep(std::time::Duration::from_millis(5));
tracker.account_elapsed();
let after = tracker.snapshot().unwrap().elapsed_ms;
assert!(after > 0, "elapsed should be > 0 after account_elapsed");
}
}