`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.
2811 lines
99 KiB
Rust
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");
|
|
}
|
|
}
|