From aa7f61673486b2026aa3936c9e1a4be4bf333d56 Mon Sep 17 00:00:00 2001 From: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:48:24 +0800 Subject: [PATCH] fix(composer): reserve context slash command alias (#5279) * fix(composer): reserve context slash command alias * fix(skills): align slash docs and formatting Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com> * fix(skills): allow context skill outside compact alias Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com> * docs(tui): sync context skill command policy Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com> --------- Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com> --- .../packages/harness/deerflow/skills/AGENTS.md | 2 +- .../packages/harness/deerflow/skills/slash.py | 14 ++++++++------ .../packages/harness/deerflow/tui/AGENTS.md | 2 +- .../harness/deerflow/tui/command_registry.py | 10 ++++++---- backend/tests/test_slash_skills.py | 18 +++++++++++++++--- backend/tests/test_tui_command_registry.py | 13 +++++++++++-- contracts/slash_skill_contract.json | 1 + .../components/workspace/input-box-helpers.ts | 10 ++++------ frontend/src/core/skills/slash.ts | 14 ++++++++++---- .../workspace/input-box-helpers.test.ts | 15 +++++++++++++++ frontend/tests/unit/core/skills/slash.test.ts | 8 ++++++++ 11 files changed, 80 insertions(+), 27 deletions(-) diff --git a/backend/packages/harness/deerflow/skills/AGENTS.md b/backend/packages/harness/deerflow/skills/AGENTS.md index c5731c335..da975257a 100644 --- a/backend/packages/harness/deerflow/skills/AGENTS.md +++ b/backend/packages/harness/deerflow/skills/AGENTS.md @@ -10,7 +10,7 @@ - **Deferred discovery** (`skills.deferred_discovery: true`): Skills are listed by name only in a compact `` block, keeping the system prompt prefix-cache friendly. The agent calls the `describe_skill` tool at runtime to fetch full metadata for skills it wants to use, then loads the SKILL.md via `read_file`. Two new modules support this path: - `skills/catalog.py` — immutable `SkillCatalog`; query forms: `select:a,b`, `+prefix`, free-text intent terms. Ranked queries use up to 256 characters / 16 unique literal terms, preferring name matches over description-only matches at equal coverage; ties keep catalog order. A lazy per-catalog index caches normalized names/descriptions. Unlike tool search, ranking uses literal intent terms, not regexes. `select:` is parsed before search limits and returns all exact catalog matches without query-length or result caps; other modes cap at `MAX_RESULTS=5`. - `skills/describe.py` — `build_describe_skill_tool(catalog)` builds the `describe_skill` tool as a closure; `build_skill_search_setup(skills, enabled, ...)` produces a `SkillSearchSetup(describe_skill_tool, skill_names)` that is wired into both the LangGraph agent factory (`agent.py`) and the embedded client (`client.py`). -- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`, `/goal`, `/agent`), disabled skills, and skills outside a custom agent's whitelist. +- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved command syntax (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`, `/goal`, `/agent`, and the exact composer-only alias `/context compact`), disabled skills, and skills outside a custom agent's whitelist. A custom skill named `context` remains activatable for other task text. - **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory - **Managed integrations**: Lark/Feishu CLI support installs one global official `lark-*` pack as read-only `SkillCategory.INTEGRATION` entries under `/mnt/skills/integrations/lark-cli/...`; enabled flags, app configuration, and OAuth data remain per-user. Install resolves the newest `larksuite/cli` release from GitHub (`releases/latest`) at install time (falling back to a bottom-line pinned version if the lookup fails) rather than hard-coding the pack version; integrity relies on the official host + structural archive guards + a recorded hash of the effective installed tree after shared guidance injection (not a pinned archive-byte SHA, which GitHub does not keep stable). The Gateway image still installs a pinned `@larksuite/cli` binary, so `get_lark_integration_status` surfaces `latest_available_version` and `runtime_version_mismatch` for the UI. AIO installs additionally verify and publish official Linux amd64/arm64 binaries under `{DEER_FLOW_HOME}/integrations/lark-cli/sandbox-cli`, mounted read-only at `/mnt/integrations/lark-cli/runtime`; `/mnt/integrations/lark-cli/config` (app credentials, incl. the long-lived `appSecret`) is mounted **read-only** into the sandbox, its empty `config/locks` subdirectory is over-mounted writable for `lark-cli` coordination files, and `/mnt/integrations/lark-cli/data` (refreshable OAuth tokens) stays writable, all mapping to owner-only per-user directories. **Sandbox trust boundary:** the credential-bearing config and data dirs are still *readable* by arbitrary sandbox processes (the agent's `bash` tool, or code reached via prompt-injection in a tool result), so the app secret and tokens are exposed to sandbox-side code even though they never reach the browser — the read-only config mount only prevents in-sandbox tampering, not read/exfiltration. The sidecar credential-broker (Pattern B, issue #4338) is the fix that removes these plaintext mounts from sandbox execution: set `LARK_CLI_BROKER_IMAGE` on the provisioner (see `docker/lark-cli-broker/`) and the Gateway sends `provision_lark_cli_broker` on sandbox create. The provisioner then runs a `lark-cli-broker` sidecar that owns the per-user `config`/`config/locks`/`data` mounts (mounted into the **sidecar only**, at `/var/lark/{config,config/locks,data}` with only the nested locks mount writable) and serves the `lark-cli` command surface on Pod loopback (`http://127.0.0.1:8788`); a shim init container (`install-shim`) writes a forwarding `lark-cli` into the shared runtime `emptyDir`, so the sandbox gets `DEERFLOW_LARK_BROKER_URL` + a shim on PATH but **no** credential files. The on-PATH `bin/lark-cli` is a `/bin/sh` launcher that resolves a Python 3 interpreter and execs the Python shim body (`bin/lark-cli-shim.py`) beside it, so broker mode does not silently ENOEXEC on a sandbox image without a `#!/usr/bin/env python3`-resolvable interpreter — it fails loudly (exit 127, actionable message) and can be pinned with `DEERFLOW_LARK_BROKER_PYTHON`. The broker runs `lark-cli` in the sidecar's cwd and cannot see the sandbox filesystem, so cwd is intentionally **not** forwarded and file-I/O subcommands relative to the sandbox cwd are unsupported (command surface only). An optional `DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS` denylist (comma-separated command prefixes, forwarded from the provisioner) lets the broker refuse secret-dumping subcommands before spawning the binary. `lark_cli_env_overlay(broker=True)` therefore omits `LARKSUITE_CLI_CONFIG_DIR`/`DATA_DIR`; `sandbox_lark_broker_active()` (TTL-cached provisioner `/api/capabilities` probe, tight timeout + longer negative caching on the bash hot path) selects broker vs. binary mode for both the bash env overlay and status. `DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR` supplies a validated, symlink-free pre-staged runtime for air-gapped deployments. For the remote provisioner (K8s), the runtime binary is otherwise provisioned by an optional init container + shared `emptyDir` (Pattern A): set `LARK_CLI_INIT_IMAGE` on the provisioner (see `docker/lark-cli-init/`) and the Gateway sends `provision_lark_cli_runtime` on sandbox create once the pack is installed, so remote installs skip the Gateway-side GitHub download entirely. Broker (Pattern B) supersedes the init-container binary (Pattern A) when both images are configured. `get_lark_integration_status(check_runtime=True)` surfaces `sandbox_runtime_mode` (`none` / `gateway-download` / `init-container` / `broker`) and `sandbox_runtime_ready` (remote modes read the provisioner `GET /api/capabilities`: `lark_cli_init_image` / `lark_cli_broker_image`) so a green UI can't hide a chat-time `lark-cli: command not found`. Cheap status probes are explicitly not live-verified; users authorize or reconnect through the browser device-flow endpoints instead of running terminal commands. - **SkillScan**: `packages/harness/deerflow/skills/skillscan/` is the native deterministic scanner for `.skill` archives and agent-managed skill writes. It runs offline before the LLM scanner, emits structured findings (`rule_id`, `severity`, `file`, `line`, `message`, `remediation`, redacted `evidence` — category/analyzer are encoded in the `rule_id` prefix), blocks `CRITICAL`, and passes warning findings into `scan_skill_content()`. The moderation adapter must normalize both plain-text responses and LangChain Responses API text blocks before parsing the required JSON decision. `scan_archive_preflight()` / `scan_skill_dir()` are pure sync functions (dispatch off the event loop); `enforce_static_scan()` applies the blocking policy and the `skill_scan.enabled` kill switch. `skills/package_files.py` is the single definition of code files (`scripts/` members, code suffixes, extensionless `#!` files) and executable magic bytes; the installer, export guard, and SkillScan all import it, so do not re-derive either rule locally. Files that fail NUL-free UTF-8 decoding are treated as binaries unless they are code files: interpreters still run those, so they raise `package-undecodable-script` (HIGH) and are analyzed over a lossy decode, keeping one stray byte from hiding a file or downgrading a `CRITICAL` match; one with executable magic skips the text rules, which only misread its string tables. The Python instance-client signal deliberately follows only a one-level, same-scope evidence chain (PR #4265 review): a proven imported constructor bound to a simple name, optional name-to-name alias propagation, rebinding invalidation, and a constructor-supported outbound method or context-manager use; bare canonical-looking names never fall back to module identity. Nested scopes never inherit client handles and inherit only constructor aliases proven stable by a binding-only enclosing-scope prepass. Comprehensions, walrus-bearing statements, annotations, executable expressions inside complex binding targets, unsupported operations, and ambiguous flows produce no finding from this signal; skipped constructs invalidate all names they may bind, while representative false negatives are pinned by `test_python_declared_false_negatives_stay_unreported`. Compound bodies are walked from isolated copies so wrapping code in `if True:` is not a bypass, while copied scope entries, binding-only prepasses, and AST visits consume a deterministic work budget and the walk stops after its first sink. Budget or recursion exhaustion skips only this best-effort signal and retains deterministic findings already collected for the file. Do not add Semgrep/OpenGrep or YAML rule-engine dependencies to the core path; Phase 1 rule specs live in Python constants next to their analyzers in `skillscan/orchestrator.py`. diff --git a/backend/packages/harness/deerflow/skills/slash.py b/backend/packages/harness/deerflow/skills/slash.py index a143aa2bf..75c340792 100644 --- a/backend/packages/harness/deerflow/skills/slash.py +++ b/backend/packages/harness/deerflow/skills/slash.py @@ -6,16 +6,17 @@ from dataclasses import dataclass from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH from deerflow.skills.types import Skill -#: Composer control commands that own the leading slash and must never be -#: treated as ``/skill`` activations. These values plus :data:`_SLASH_SKILL_RE` -#: are mirrored by the frontend display parser in +#: Composer control names that may own the leading slash and must not be +#: treated as ``/skill`` activations when their command syntax matches. +#: These values plus :data:`_SLASH_SKILL_RE` are mirrored by the frontend parser in #: ``frontend/src/core/skills/slash.ts``; both sides are pinned to the shared #: fixture at ``contracts/slash_skill_contract.json`` by contract tests #: (``tests/test_slash_skill_contract.py`` here, ``slash-contract.test.ts`` on #: the frontend), so a reserved command or grammar change in only one language #: fails CI. -RESERVED_SLASH_SKILL_NAMES = frozenset({"agent", "bootstrap", "goal", "help", "memory", "models", "new", "status"}) +RESERVED_SLASH_SKILL_NAMES = frozenset({"agent", "bootstrap", "context", "goal", "help", "memory", "models", "new", "status"}) _SLASH_SKILL_RE = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)") +_CONTEXT_COMPACT_ARGUMENT = "compact" @dataclass(frozen=True, slots=True) @@ -41,11 +42,12 @@ def parse_slash_skill_reference(text: str) -> SlashSkillReference | None: if not match: return None name = match.group(1) - if name in RESERVED_SLASH_SKILL_NAMES: + remaining_text = text[match.end() :].lstrip() + if name in RESERVED_SLASH_SKILL_NAMES and not (name == "context" and remaining_text.strip().casefold() != _CONTEXT_COMPACT_ARGUMENT): return None return SlashSkillReference( name=name, - remaining_text=text[match.end() :].lstrip(), + remaining_text=remaining_text, ) diff --git a/backend/packages/harness/deerflow/tui/AGENTS.md b/backend/packages/harness/deerflow/tui/AGENTS.md index 1c56d0c41..730d9abb5 100644 --- a/backend/packages/harness/deerflow/tui/AGENTS.md +++ b/backend/packages/harness/deerflow/tui/AGENTS.md @@ -6,7 +6,7 @@ A terminal-native UI over the embedded harness, exposed as the `deerflow` consol - `cli.py` — `plan_launch()` (pure launch-mode decision) + headless `--print` / `--json` + `main()` entry point. TTY → TUI, else headless help. `--tui-transparent` / `DEER_FLOW_TUI_TRANSPARENT` opt into terminal-default backgrounds without changing the solid-theme default. Uses an **absolute** `from deerflow.tui.app import run_tui` so the `app.py` module name doesn't trip `test_harness_boundary.py` (which records relative import module names verbatim). - `view_state.py` — `ViewState` + `reduce(state, action)`, the testable heart. Rows: user / assistant / tool / system. Title captured from `values` events. - `runtime.py` — `translate(StreamEvent) -> [Action]` (pure) + `stream_actions()` which brackets a run with `RunStarted`/`RunEnded` and turns model errors into an `AssistantError` row. -- `message_format.py` / `command_registry.py` / `input_history.py` / `render.py` / `theme.py` — pure helpers (tool summaries, slash registry + `resolve()`, ↑/↓ history, Rich renderers). The command registry must exclude the shared `RESERVED_SLASH_SKILL_NAMES` from both its picker and resolver so the TUI cannot advertise a skill activation that the agent runtime rejects. +- `message_format.py` / `command_registry.py` / `input_history.py` / `render.py` / `theme.py` — pure helpers (tool summaries, slash registry + `resolve()`, ↑/↓ history, Rich renderers). The command registry must exclude every shared `RESERVED_SLASH_SKILL_NAMES` entry that the agent runtime rejects from both its picker and resolver; the `context` skill is the exception for ordinary task text, while the exact composer-only `/context compact` alias remains unavailable as a skill activation. - `app.py` — Textual `App`. Runs `DeerFlowClient.stream()` (sync) on a worker thread and marshals actions to the UI thread via `call_from_thread`. Slash palette with `/goal` management + model/thread modal pickers; routes idle display-only `/clear` through `ClearRows` without replacing the active thread, and blocks state-resetting local commands like `/new` and `/clear` with the standard "Still working" message during an active run; priority key bindings gated by `check_action` so they never steal keys from overlays or the composer. Application-level PageUp/PageDown bindings scroll the transcript while preserving composer focus; streaming follows output only while the transcript remains at the bottom. - `session.py` / `persistence.py` — builds the client + checkpointer and the `ThreadMetaWriter`. diff --git a/backend/packages/harness/deerflow/tui/command_registry.py b/backend/packages/harness/deerflow/tui/command_registry.py index 134f32013..86887e848 100644 --- a/backend/packages/harness/deerflow/tui/command_registry.py +++ b/backend/packages/harness/deerflow/tui/command_registry.py @@ -16,7 +16,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Literal -from deerflow.skills.slash import RESERVED_SLASH_SKILL_NAMES +from deerflow.skills.slash import RESERVED_SLASH_SKILL_NAMES, parse_slash_skill_reference @dataclass(frozen=True) @@ -77,7 +77,7 @@ def build_registry(skills: list[dict]) -> list[Command]: if not skill.get("enabled", False): continue name = skill.get("name") - if not name or name in _BUILTIN_NAMES or name in RESERVED_SLASH_SKILL_NAMES: + if not name or name in _BUILTIN_NAMES or (name in RESERVED_SLASH_SKILL_NAMES and name != "context"): continue commands.append(Command(name=name, description=skill.get("description", "") or "", category="skill")) return commands @@ -124,7 +124,9 @@ def resolve(text: str, skills: list[str] | None = None) -> Resolution: if name in _BUILTIN_NAMES: return Resolution(kind="builtin", name=name, args=args) - if skills and name in skills and name not in RESERVED_SLASH_SKILL_NAMES: - return Resolution(kind="skill", name=name, args=args) + if skills and name in skills: + reference = parse_slash_skill_reference(f"/{name} {args}".rstrip()) + if reference is not None: + return Resolution(kind="skill", name=name, args=args) return Resolution(kind="unknown", name=name, args=args) diff --git a/backend/tests/test_slash_skills.py b/backend/tests/test_slash_skills.py index e4c993315..ea41869e4 100644 --- a/backend/tests/test_slash_skills.py +++ b/backend/tests/test_slash_skills.py @@ -89,14 +89,26 @@ def test_parse_slash_skill_reference_rejects_invalid_names(): def test_resolve_slash_skill_ignores_reserved_control_commands(tmp_path): - for command in ["agent", "bootstrap", "goal", "help", "memory", "models", "new", "status"]: + for command in sorted(RESERVED_SLASH_SKILL_NAMES): + if command == "context": + continue skill = _make_skill(tmp_path, command) assert resolve_slash_skill(f"/{command} create an agent", [skill]) is None -def test_reserved_slash_skill_names_match_channel_commands(): - assert RESERVED_SLASH_SKILL_NAMES == {command.removeprefix("/") for command in KNOWN_CHANNEL_COMMANDS} +def test_channel_commands_are_reserved_slash_skill_names(): + assert {command.removeprefix("/") for command in KNOWN_CHANNEL_COMMANDS} <= RESERVED_SLASH_SKILL_NAMES + + +def test_context_compact_alias_is_reserved_without_becoming_channel_command(tmp_path): + assert "context" in RESERVED_SLASH_SKILL_NAMES + assert "/context" not in KNOWN_CHANNEL_COMMANDS + + context_skill = _make_skill(tmp_path, "context") + assert parse_slash_skill_reference("/context compact") is None + assert parse_slash_skill_reference("/context use the skill") is not None + assert resolve_slash_skill("/context use the skill", [context_skill]) is not None def test_resolve_slash_skill_respects_available_skill_whitelist(tmp_path): diff --git a/backend/tests/test_tui_command_registry.py b/backend/tests/test_tui_command_registry.py index 5fe047f16..b7fbcc1a4 100644 --- a/backend/tests/test_tui_command_registry.py +++ b/backend/tests/test_tui_command_registry.py @@ -123,7 +123,7 @@ def test_build_registry_never_exposes_reserved_commands_as_skills(): registry = build_registry([{"name": name, "description": "reserved", "enabled": True} for name in RESERVED_SLASH_SKILL_NAMES]) skill_names = {command.name for command in registry if command.category == "skill"} - assert skill_names.isdisjoint(RESERVED_SLASH_SKILL_NAMES) + assert skill_names == {"context"} def test_resolve_never_classifies_reserved_commands_as_skills(): @@ -131,7 +131,16 @@ def test_resolve_never_classifies_reserved_commands_as_skills(): for name in reserved_names: resolved = resolve(f"/{name} task", skills=reserved_names) - assert resolved.kind != "skill", name + if name == "context": + assert resolved.kind == "skill" + else: + assert resolved.kind != "skill", name + + +def test_context_compact_alias_is_not_a_tui_skill(): + resolved = resolve("/context compact", skills=["context"]) + + assert resolved.kind == "unknown" # --------------------------------------------------------------------------- # diff --git a/contracts/slash_skill_contract.json b/contracts/slash_skill_contract.json index f8770dd4e..21cb4b282 100644 --- a/contracts/slash_skill_contract.json +++ b/contracts/slash_skill_contract.json @@ -4,6 +4,7 @@ "reserved_slash_skill_names": [ "agent", "bootstrap", + "context", "goal", "help", "memory", diff --git a/frontend/src/components/workspace/input-box-helpers.ts b/frontend/src/components/workspace/input-box-helpers.ts index 5373f6235..b4a895978 100644 --- a/frontend/src/components/workspace/input-box-helpers.ts +++ b/frontend/src/components/workspace/input-box-helpers.ts @@ -179,11 +179,9 @@ export function getMatchingSkillSuggestions( builtinCommands: SlashSuggestion[], ): SlashSuggestion[] { const normalizedQuery = query.toLowerCase(); - // A name the slash parsers refuse must not be offered here either. Both - // parsers drop `RESERVED_SLASH_SKILL_NAMES` (the shared contract), and the - // builtin commands own their own names in the composer, so a skill carrying - // either one is unreachable: submitting it either runs the command or - // reaches the model as literal text with nothing activated. + // A name the slash parser refuses must not be offered here either. Builtin + // names remain unavailable, while `context` is only reserved for the exact + // `/context compact` alias and can therefore still be a skill suggestion. const reservedNames = new Set([ ...RESERVED_SLASH_SKILL_NAMES, ...builtinCommands.map(({ name }) => name.toLowerCase()), @@ -209,7 +207,7 @@ export function getMatchingSkillSuggestions( if (!skill.enabled) { return false; } - if (reservedNames.has(name)) { + if (reservedNames.has(name) && name !== "context") { return false; } return !normalizedQuery || name.includes(normalizedQuery); diff --git a/frontend/src/core/skills/slash.ts b/frontend/src/core/skills/slash.ts index 34b06908d..dbb87dc78 100644 --- a/frontend/src/core/skills/slash.ts +++ b/frontend/src/core/skills/slash.ts @@ -1,8 +1,8 @@ import type { Skill } from "./type"; /** - * Composer control commands that own the leading slash. They must never be - * shown as skill activations. These values plus {@link SLASH_SKILL_RE} mirror + * Composer control names that may own the leading slash. Their command syntax + * must not be shown as a skill activation. These values plus {@link SLASH_SKILL_RE} mirror * the backend gate in `deerflow/skills/slash.py`; both sides are pinned to the * shared fixture at `contracts/slash_skill_contract.json` by contract tests * (`tests/unit/core/skills/slash-contract.test.ts` here, @@ -12,6 +12,7 @@ import type { Skill } from "./type"; export const RESERVED_SLASH_SKILL_NAMES = new Set([ "agent", "bootstrap", + "context", "goal", "help", "memory", @@ -21,6 +22,7 @@ export const RESERVED_SLASH_SKILL_NAMES = new Set([ ]); export const SLASH_SKILL_RE = /^\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)/; +const CONTEXT_COMPACT_ARGUMENT = "compact"; export type SlashSkillReference = { name: string; @@ -40,12 +42,16 @@ export function parseSlashSkillReference( return null; } const name = match[1]; - if (!name || RESERVED_SLASH_SKILL_NAMES.has(name)) { + const remainingText = text.slice(match[0].length).replace(/^\s+/, ""); + const isContextSkillTask = + name === "context" && + remainingText.trim().toLowerCase() !== CONTEXT_COMPACT_ARGUMENT; + if (!name || (RESERVED_SLASH_SKILL_NAMES.has(name) && !isContextSkillTask)) { return null; } return { name, - remainingText: text.slice(match[0].length).replace(/^\s+/, ""), + remainingText, }; } diff --git a/frontend/tests/unit/components/workspace/input-box-helpers.test.ts b/frontend/tests/unit/components/workspace/input-box-helpers.test.ts index 893e1ffac..11caddaa4 100644 --- a/frontend/tests/unit/components/workspace/input-box-helpers.test.ts +++ b/frontend/tests/unit/components/workspace/input-box-helpers.test.ts @@ -380,6 +380,9 @@ describe("getMatchingSkillSuggestions", () => { // these names, so such a skill can never activate — picking it would send // literal text to the model with nothing loaded. for (const reserved of RESERVED_SLASH_SKILL_NAMES) { + if (reserved === "context") { + continue; + } const result = getMatchingSkillSuggestions( [makeSkill(reserved), makeSkill(`${reserved}-helper`)], reserved, @@ -398,6 +401,18 @@ describe("getMatchingSkillSuggestions", () => { expect(result).toEqual([]); }); + it("keeps a context skill available because only its compact alias is reserved", () => { + const result = getMatchingSkillSuggestions( + [makeSkill("context")], + "context", + [], + ); + + expect( + result.map((suggestion) => `${suggestion.kind}:${suggestion.name}`), + ).toEqual(["skill:context"]); + }); + it("caps the number of suggestions", () => { const skills = Array.from({ length: 10 }, (_, i) => makeSkill(`skill-${i}`), diff --git a/frontend/tests/unit/core/skills/slash.test.ts b/frontend/tests/unit/core/skills/slash.test.ts index e832b43f9..31db3ea99 100644 --- a/frontend/tests/unit/core/skills/slash.test.ts +++ b/frontend/tests/unit/core/skills/slash.test.ts @@ -34,6 +34,14 @@ describe("parseSlashSkillReference", () => { expect(parseSlashSkillReference("/help")).toBeNull(); }); + it("allows a context skill except for the compact alias", () => { + expect(parseSlashSkillReference("/context compact")).toBeNull(); + expect(parseSlashSkillReference("/context use this skill")).toEqual({ + name: "context", + remainingText: "use this skill", + }); + }); + it("returns null when text is not a leading slash command", () => { expect(parseSlashSkillReference("hello /data-analysis")).toBeNull(); expect(parseSlashSkillReference("/a/b")).toBeNull();