diff --git a/backend/packages/harness/deerflow/skills/slash.py b/backend/packages/harness/deerflow/skills/slash.py index c2540f6de..37b7e46cf 100644 --- a/backend/packages/harness/deerflow/skills/slash.py +++ b/backend/packages/harness/deerflow/skills/slash.py @@ -6,6 +6,14 @@ 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 +#: ``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({"bootstrap", "goal", "help", "memory", "models", "new", "status"}) _SLASH_SKILL_RE = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)") diff --git a/backend/tests/test_slash_skill_contract.py b/backend/tests/test_slash_skill_contract.py new file mode 100644 index 000000000..8f558419b --- /dev/null +++ b/backend/tests/test_slash_skill_contract.py @@ -0,0 +1,37 @@ +"""Contract tests for the leading ``/skill`` activation gate. + +Pins the backend parser's reserved-command set and skill-name grammar to the +shared fixture at ``contracts/slash_skill_contract.json``. The frontend display +parser (``frontend/src/core/skills/slash.ts``) is pinned to the same fixture by +``frontend/tests/unit/core/skills/slash-contract.test.ts``, so a reserved +command added on one side—or a grammar change—cannot silently drift the two +languages apart. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from deerflow.skills.slash import _SLASH_SKILL_RE, RESERVED_SLASH_SKILL_NAMES + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_CONTRACT_PATH = _REPO_ROOT / "contracts" / "slash_skill_contract.json" + + +def _load_contract() -> dict: + return json.loads(_CONTRACT_PATH.read_text(encoding="utf-8")) + + +def test_contract_file_exists(): + assert _CONTRACT_PATH.is_file(), f"missing shared fixture: {_CONTRACT_PATH}" + + +def test_reserved_names_match_contract(): + contract = _load_contract() + assert set(RESERVED_SLASH_SKILL_NAMES) == set(contract["reserved_slash_skill_names"]) + + +def test_skill_name_pattern_matches_contract(): + contract = _load_contract() + assert _SLASH_SKILL_RE.pattern == contract["skill_name_pattern"] diff --git a/contracts/slash_skill_contract.json b/contracts/slash_skill_contract.json new file mode 100644 index 000000000..d47653724 --- /dev/null +++ b/contracts/slash_skill_contract.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "description": "Cross-language contract fixture for the leading /skill activation gate. The backend parser (deerflow/skills/slash.py) and the frontend display parser (frontend/src/core/skills/slash.ts) must agree on which leading /word tokens are reserved control commands and on the exact skill-name grammar, so the transcript only renders an activation chip for text the backend would actually treat as a /skill activation.", + "reserved_slash_skill_names": ["bootstrap", "goal", "help", "memory", "models", "new", "status"], + "skill_name_pattern": "^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\\s+|$)" +} diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index a000663cc..60e390355 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -25,6 +25,8 @@ import { useRef, useState, type ComponentProps, + type ClipboardEvent, + type FormEvent, type KeyboardEvent, } from "react"; import { toast } from "sonner"; @@ -37,7 +39,6 @@ import { PromptInputActionMenuTrigger, PromptInputAttachment, PromptInputAttachments, - PromptInputBody, PromptInputButton, PromptInputFooter, PromptInputHeader, @@ -82,6 +83,7 @@ import { threadTokenUsageQueryKey } from "@/core/threads/token-usage"; import { textOfMessage } from "@/core/threads/utils"; import { formatUploadSize, + splitUnsupportedUploadFiles, useUploadLimits, validateUploadLimits, type UploadLimits, @@ -126,10 +128,51 @@ import { import { useThread } from "./messages/context"; import { ModeHoverGuide } from "./mode-hover-guide"; import { ReferenceAttachmentSummary, useMaybeSidecar } from "./sidecar"; +import { SlashSkillChip } from "./slash-skill-chip"; import { Tooltip } from "./tooltip"; type InputMode = "flash" | "thinking" | "pro" | "ultra"; +function focusContentEditableEnd(element: HTMLElement | null) { + if (!element) { + return; + } + + element.focus(); + const selection = window.getSelection(); + if (!selection) { + return; + } + + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); +} + +function insertPlainTextAtSelection(container: HTMLElement, text: string) { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return false; + } + + const range = selection.getRangeAt(0); + const ancestor = range.commonAncestorContainer; + if (ancestor !== container && !container.contains(ancestor)) { + return false; + } + + range.deleteContents(); + const node = document.createTextNode(text); + range.insertNode(node); + range.setStartAfter(node); + range.setEndAfter(node); + selection.removeAllRanges(); + selection.addRange(range); + return true; +} + function getResolvedMode( mode: InputMode | undefined, supportsThinking: boolean, @@ -272,6 +315,8 @@ export function InputBox({ const { data: uploadLimits } = useUploadLimits(threadId); const promptRootRef = useRef(null); const textareaRef = useRef(null); + const inlineSkillTextRef = useRef(null); + const inlineSkillComposingRef = useRef(false); const goalRequestStateRef = useRef(createGoalRequestState()); const compactRequestStateRef = useRef(createGoalRequestState()); const inputPolishRequestRef = useRef<{ @@ -297,6 +342,8 @@ export function InputBox({ } | null>(null); const [textareaFocused, setTextareaFocused] = useState(false); const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0); + const [selectedSlashSkill, setSelectedSlashSkill] = + useState(null); const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] = useState(null); const lastGeneratedForAiIdRef = useRef(null); @@ -451,6 +498,7 @@ export function InputBox({ useEffect(() => { promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; + setSelectedSlashSkill(null); setInputPolishUndo(null); }, [threadId]); @@ -804,9 +852,15 @@ export function InputBox({ toast.info(t.inputBox.pleaseWaitStreaming); return Promise.reject(new Error("streaming")); } + const messageWithSlashSkill = selectedSlashSkill + ? { + ...message, + text: `/${selectedSlashSkill.name} ${message.text}`, + } + : message; const submitAction = getInputSubmitAction({ - text: message.text, - fileCount: message.files.length, + text: messageWithSlashSkill.text, + fileCount: messageWithSlashSkill.files.length, status, }); if (submitAction.kind === "goal") { @@ -836,12 +890,16 @@ export function InputBox({ if (submitAction.kind === "empty") { return; } - return submitThreadMessage(message); + await submitThreadMessage(messageWithSlashSkill); + if (selectedSlashSkill) { + setSelectedSlashSkill(null); + } }, [ handleCompactCommand, handleGoalCommand, onStop, + selectedSlashSkill, status, submitThreadMessage, t.inputBox.pleaseWaitStreaming, @@ -917,6 +975,7 @@ export function InputBox({ const showSkillSuggestions = !disabled && textareaFocused && + !selectedSlashSkill && slashSkillQuery !== null && skillSuggestions.length > 0 && dismissedSkillSuggestionValue !== textInput.value; @@ -951,6 +1010,16 @@ export function InputBox({ const applySkillSuggestion = useCallback( (suggestion: SlashSuggestion) => { + if (suggestion.kind === "skill") { + setSelectedSlashSkill(suggestion); + textInput.setInput(""); + setDismissedSkillSuggestionValue(null); + requestAnimationFrame(() => { + focusContentEditableEnd(inlineSkillTextRef.current); + }); + return; + } + const nextValue = `/${suggestion.name} `; textInput.setInput(nextValue); setDismissedSkillSuggestionValue(nextValue); @@ -967,7 +1036,7 @@ export function InputBox({ ); const handleSkillSuggestionKeyDown = useCallback( - (event: KeyboardEvent) => { + (event: KeyboardEvent) => { if (!showSkillSuggestions) { return; } @@ -1119,13 +1188,14 @@ export function InputBox({ }, [inputPolishUndo, inputPolishUndoAvailable, setPromptHistoryValue]); const handlePromptHistoryKeyDown = useCallback( - (event: KeyboardEvent) => { + (event: KeyboardEvent) => { if ( event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || isIMEComposing(event) || + selectedSlashSkill || promptHistory.length === 0 || (event.key !== "ArrowUp" && event.key !== "ArrowDown") ) { @@ -1169,18 +1239,46 @@ export function InputBox({ promptHistoryIndexRef.current = nextIndex; setPromptHistoryValue(promptHistory[nextIndex] ?? ""); }, - [promptHistory, setPromptHistoryValue, textInput.value], + [promptHistory, selectedSlashSkill, setPromptHistoryValue, textInput.value], + ); + + const handleSelectedSlashSkillKeyDown = useCallback( + (event: KeyboardEvent) => { + if ( + event.key !== "Backspace" || + !selectedSlashSkill || + textInput.value.length > 0 || + isIMEComposing(event) + ) { + return; + } + + event.preventDefault(); + setSelectedSlashSkill(null); + requestAnimationFrame(() => { + textareaRef.current?.focus(); + }); + }, + [selectedSlashSkill, textInput.value], ); const handlePromptTextareaKeyDown = useCallback( - (event: KeyboardEvent) => { + (event: KeyboardEvent) => { handleSkillSuggestionKeyDown(event); if (event.defaultPrevented) { return; } + handleSelectedSlashSkillKeyDown(event); + if (event.defaultPrevented) { + return; + } handlePromptHistoryKeyDown(event); }, - [handlePromptHistoryKeyDown, handleSkillSuggestionKeyDown], + [ + handlePromptHistoryKeyDown, + handleSelectedSlashSkillKeyDown, + handleSkillSuggestionKeyDown, + ], ); const handlePromptTextareaChange = useCallback(() => { @@ -1190,10 +1288,108 @@ export function InputBox({ promptHistoryDraftRef.current = ""; }, [abortInputPolishRequest]); + const updateInlineSkillTextInput = useCallback( + (element: HTMLElement) => { + promptHistoryIndexRef.current = null; + promptHistoryDraftRef.current = ""; + textInput.setInput(element.textContent ?? ""); + }, + [textInput], + ); + + useEffect(() => { + if (!selectedSlashSkill) { + return; + } + + const element = inlineSkillTextRef.current; + if (element && element.textContent !== textInput.value) { + element.textContent = textInput.value; + } + }, [selectedSlashSkill, textInput.value]); + + const handleInlineSkillInput = useCallback( + (event: FormEvent) => { + updateInlineSkillTextInput(event.currentTarget); + }, + [updateInlineSkillTextInput], + ); + + const handleInlineSkillPaste = useCallback( + (event: ClipboardEvent) => { + const pastedFiles = Array.from(event.clipboardData.items) + .filter((item) => item.kind === "file") + .flatMap((item) => { + const file = item.getAsFile(); + return file ? [file] : []; + }); + + if (pastedFiles.length > 0) { + event.preventDefault(); + const { accepted, message } = splitUnsupportedUploadFiles(pastedFiles); + if (message) { + toast.error(message); + } + if (accepted.length > 0) { + attachments.add(accepted); + } + return; + } + + const text = event.clipboardData.getData("text/plain"); + if (!text) { + return; + } + + event.preventDefault(); + if (insertPlainTextAtSelection(event.currentTarget, text)) { + updateInlineSkillTextInput(event.currentTarget); + } + }, + [attachments, updateInlineSkillTextInput], + ); + + const handleInlineSkillKeyDown = useCallback( + (event: KeyboardEvent) => { + handleSelectedSlashSkillKeyDown(event); + if (event.defaultPrevented) { + return; + } + + if (event.key !== "Enter") { + return; + } + + if (isIMEComposing(event, inlineSkillComposingRef.current)) { + return; + } + + event.preventDefault(); + + if (event.shiftKey) { + if (insertPlainTextAtSelection(event.currentTarget, "\n")) { + updateInlineSkillTextInput(event.currentTarget); + } + return; + } + + event.currentTarget.closest("form")?.requestSubmit(); + }, + [handleSelectedSlashSkillKeyDown, updateInlineSkillTextInput], + ); + + const clearSelectedSlashSkill = useCallback(() => { + setSelectedSlashSkill(null); + requestAnimationFrame(() => { + textareaRef.current?.focus(); + }); + }, []); + const showFollowups = !disabled && !isWelcomeMode && !showSkillSuggestions && + !selectedSlashSkill && !followupsHidden && (followupsLoading || followups.length > 0); @@ -1458,20 +1654,67 @@ export function InputBox({ /> )} - - setTextareaFocused(false)} - onChange={handlePromptTextareaChange} - onFocus={() => setTextareaFocused(true)} - onKeyDown={handlePromptTextareaKeyDown} - ref={textareaRef} - /> - +
+ {selectedSlashSkill ? ( +
{ + if (event.target === event.currentTarget) { + focusContentEditableEnd(inlineSkillTextRef.current); + } + }} + > + + setTextareaFocused(false)} + onCompositionEnd={() => { + inlineSkillComposingRef.current = false; + }} + onCompositionStart={() => { + inlineSkillComposingRef.current = true; + }} + onFocus={() => setTextareaFocused(true)} + onInput={handleInlineSkillInput} + onKeyDown={handleInlineSkillKeyDown} + onPaste={handleInlineSkillPaste} + aria-placeholder={t.inputBox.placeholder} + ref={inlineSkillTextRef} + role="textbox" + suppressContentEditableWarning + className={cn( + "outline-none", + "before:text-muted-foreground before:pointer-events-none", + "data-[empty=true]:before:content-[attr(data-placeholder)]", + composerLocked && "cursor-not-allowed opacity-50", + )} + tabIndex={composerLocked ? -1 : 0} + /> +
+ ) : ( + setTextareaFocused(false)} + onChange={handlePromptTextareaChange} + onFocus={() => setTextareaFocused(true)} + onKeyDown={handlePromptTextareaKeyDown} + ref={textareaRef} + /> + )} +
{/* TODO: Add more connectors here @@ -1878,6 +2121,7 @@ export function InputBox({ {isWelcomeMode && searchParams.get("mode") !== "skill" && + !selectedSlashSkill && !showSkillSuggestions && (
diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index c807bd6f3..d97645ea9 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -44,6 +44,11 @@ import { } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { readReferenceMessageContexts } from "@/core/sidecar"; +import { + parseSlashSkillReference, + resolveSlashSkillDisplay, +} from "@/core/skills"; +import { useSkills } from "@/core/skills/hooks"; import { SafeReasoningContent } from "@/core/streamdown/components"; import { cn } from "@/lib/utils"; @@ -51,6 +56,7 @@ import { WorkspaceChangeBadge } from "../changes"; import { CitationSourcesPanel } from "../citations/citation-sources-panel"; import { CopyButton } from "../copy-button"; import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments"; +import { SlashSkillChip } from "../slash-skill-chip"; import { MarkdownContent } from "./markdown-content"; import { createMarkdownLinkComponent } from "./markdown-link"; @@ -212,6 +218,42 @@ function MessageImage({ const clientTurnDurations = new Map(); +function HumanMessageText({ content }: { content: string }) { + // `parseSlashSkillReference` is a pure regex gate (no data subscription), so + // the overwhelmingly common plain-text human message never subscribes to the + // skills query. Only a message that literally looks like a `/skill …` + // activation mounts `HumanSlashSkillText`, which owns the `useSkills()` + // lookup. This keeps a skill-enabled toggle from re-rendering every human + // turn — only the few slash-candidate turns react to catalog changes. + const reference = useMemo(() => parseSlashSkillReference(content), [content]); + + if (!reference) { + return
{content}
; + } + + return ; +} + +function HumanSlashSkillText({ content }: { content: string }) { + const { skills } = useSkills(); + const slashSkill = resolveSlashSkillDisplay(content, skills); + + if (!slashSkill) { + return
{content}
; + } + + return ( +
+ + {slashSkill.remainingText && ( + + {slashSkill.remainingText} + + )} +
+ ); +} + function MessageContent_({ className, message, @@ -378,9 +420,7 @@ function MessageContent_({ {filesList} {contentToDisplay && ( -
- {contentToDisplay} -
+
)}
diff --git a/frontend/src/components/workspace/slash-skill-chip.tsx b/frontend/src/components/workspace/slash-skill-chip.tsx new file mode 100644 index 000000000..61427d6a8 --- /dev/null +++ b/frontend/src/components/workspace/slash-skill-chip.tsx @@ -0,0 +1,49 @@ +import { XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * Shared visual for a `/skill` activation, used both as a removable chip in the + * composer and as a read-only chip in the chat transcript. Keeping a single + * source of truth means the two stay in lockstep instead of drifting apart in + * their Tailwind classes. + */ +const CHIP_BASE_CLASS = + "border-primary/20 bg-primary/10 text-primary inline-flex h-6 shrink-0 items-center rounded-md border px-1.5 font-mono text-xs leading-none font-medium shadow-xs"; + +export function SlashSkillChip({ + name, + className, + onRemove, + removeLabel, +}: { + name: string; + className?: string; + /** When provided, the chip renders as a removable button with a close icon. */ + onRemove?: () => void; + removeLabel?: string; +}) { + if (onRemove) { + return ( + + ); + } + + return ( + + /{name} + + ); +} diff --git a/frontend/src/core/skills/index.ts b/frontend/src/core/skills/index.ts index 7ac274e08..a49f0e57f 100644 --- a/frontend/src/core/skills/index.ts +++ b/frontend/src/core/skills/index.ts @@ -1,2 +1,3 @@ export * from "./api"; +export * from "./slash"; export * from "./type"; diff --git a/frontend/src/core/skills/slash.ts b/frontend/src/core/skills/slash.ts new file mode 100644 index 000000000..7854aad43 --- /dev/null +++ b/frontend/src/core/skills/slash.ts @@ -0,0 +1,69 @@ +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 + * 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, + * `tests/test_slash_skill_contract.py` on the backend), so adding a reserved + * command or changing the name grammar in only one language fails CI. + */ +export const RESERVED_SLASH_SKILL_NAMES = new Set([ + "bootstrap", + "goal", + "help", + "memory", + "models", + "new", + "status", +]); + +export const SLASH_SKILL_RE = /^\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)/; + +export type SlashSkillReference = { + name: string; + remainingText: string; +}; + +/** + * Parse strict `/skill-name task` syntax, ignoring reserved control commands. + * Mirrors the backend `parse_slash_skill_reference`; returns null when the text + * is not a slash-skill activation. + */ +export function parseSlashSkillReference( + text: string, +): SlashSkillReference | null { + const match = SLASH_SKILL_RE.exec(text); + if (!match) { + return null; + } + const name = match[1]; + if (!name || RESERVED_SLASH_SKILL_NAMES.has(name)) { + return null; + } + return { + name, + remainingText: text.slice(match[0].length).replace(/^\s+/, ""), + }; +} + +/** + * Resolve a slash-skill reference against the enabled skill catalog, matching + * the backend `resolve_slash_skill` gate: only an installed + enabled skill + * activates. Returns null when the text is not a slash command or the skill is + * unknown/disabled, so callers fall back to plain-text rendering. + */ +export function resolveSlashSkillDisplay( + text: string, + skills: Skill[], +): SlashSkillReference | null { + const reference = parseSlashSkillReference(text); + if (!reference) { + return null; + } + const enabled = skills.some( + (skill) => skill.enabled && skill.name === reference.name, + ); + return enabled ? reference : null; +} diff --git a/frontend/src/lib/ime.ts b/frontend/src/lib/ime.ts index 0c6f26f55..5ccd913d8 100644 --- a/frontend/src/lib/ime.ts +++ b/frontend/src/lib/ime.ts @@ -1,6 +1,6 @@ import type { KeyboardEvent } from "react"; -type IMEKeyboardEvent = KeyboardEvent; +type IMEKeyboardEvent = KeyboardEvent; export function isIMEComposing( event: IMEKeyboardEvent, diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts index fe8bdf156..0e0d108c0 100644 --- a/frontend/tests/e2e/chat.spec.ts +++ b/frontend/tests/e2e/chat.spec.ts @@ -179,6 +179,29 @@ test.describe("Chat workspace", () => { }); test("suggests matching skills after a leading slash", async ({ page }) => { + let submittedText: string | undefined; + await page.route("**/runs/stream", (route) => { + const body = route.request().postDataJSON() as { + input?: { messages?: Array<{ content?: unknown }> }; + }; + const content = body.input?.messages?.at(-1)?.content; + if (typeof content === "string") { + submittedText = content; + } else if (Array.isArray(content)) { + submittedText = content + .map((block) => + typeof block === "object" && + block !== null && + "text" in block && + typeof block.text === "string" + ? block.text + : "", + ) + .join(""); + } + return handleRunStream(route); + }); + await page.goto("/workspace/chats/new"); const textarea = page.getByPlaceholder(/how can i assist you/i); @@ -194,7 +217,18 @@ test.describe("Chat workspace", () => { await textarea.press("Enter"); - await expect(textarea).toHaveValue("/data-analysis "); + await expect(page.getByText("/data-analysis")).toBeVisible(); + const skillInput = page.getByRole("textbox", { + name: /how can i assist you/i, + }); + await expect(skillInput).toBeVisible(); + + await skillInput.fill("summarize this dataset"); + await skillInput.press("Enter"); + + await expect + .poll(() => submittedText) + .toBe("/data-analysis summarize this dataset"); }); test("goal command sets a goal and starts an agent run", async ({ page }) => { @@ -299,7 +333,10 @@ test.describe("Chat workspace", () => { await textarea.press("ArrowDown"); await textarea.press("Enter"); - await expect(textarea).toHaveValue("/frontend-design "); + await expect(page.getByText("/frontend-design")).toBeVisible(); + await expect( + page.getByRole("textbox", { name: /how can i assist you/i }), + ).toBeVisible(); }); test("keeps Shift+Enter as newline while skill suggestions are visible", async ({ diff --git a/frontend/tests/unit/core/skills/slash-contract.test.ts b/frontend/tests/unit/core/skills/slash-contract.test.ts new file mode 100644 index 000000000..6d2020d61 --- /dev/null +++ b/frontend/tests/unit/core/skills/slash-contract.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "@rstest/core"; + +import { + RESERVED_SLASH_SKILL_NAMES, + SLASH_SKILL_RE, +} from "@/core/skills/slash"; + +interface ContractFile { + reserved_slash_skill_names: string[]; + skill_name_pattern: string; +} + +const CONTRACT_PATH = resolve( + __dirname, + "../../../../../contracts/slash_skill_contract.json", +); +const CONTRACT: ContractFile = JSON.parse( + readFileSync(CONTRACT_PATH, "utf-8"), +) as ContractFile; + +describe("slash-skill contract", () => { + it("reserved names match the shared contract fixture", () => { + expect([...RESERVED_SLASH_SKILL_NAMES].sort()).toEqual( + [...CONTRACT.reserved_slash_skill_names].sort(), + ); + }); + + it("skill-name grammar matches the shared contract fixture", () => { + // The contract stores the Python-canonical pattern (an unescaped `/`). + // Normalize both through the RegExp constructor so the comparison is not + // tripped by JS escaping the delimiter to `\/` in `.source`. + expect(SLASH_SKILL_RE.source).toBe( + new RegExp(CONTRACT.skill_name_pattern).source, + ); + }); +}); diff --git a/frontend/tests/unit/core/skills/slash.test.ts b/frontend/tests/unit/core/skills/slash.test.ts new file mode 100644 index 000000000..e832b43f9 --- /dev/null +++ b/frontend/tests/unit/core/skills/slash.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "@rstest/core"; + +import type { Skill } from "@/core/skills"; +import { + parseSlashSkillReference, + resolveSlashSkillDisplay, +} from "@/core/skills/slash"; + +function makeSkill(name: string, enabled = true): Skill { + return { + name, + description: `${name} description`, + enabled, + } as Skill; +} + +describe("parseSlashSkillReference", () => { + it("parses a leading /skill and captures the remaining text", () => { + expect(parseSlashSkillReference("/data-analysis summarize this")).toEqual({ + name: "data-analysis", + remainingText: "summarize this", + }); + }); + + it("parses a bare /skill with no task text", () => { + expect(parseSlashSkillReference("/data-analysis")).toEqual({ + name: "data-analysis", + remainingText: "", + }); + }); + + it("ignores reserved control commands", () => { + expect(parseSlashSkillReference("/goal ship it")).toBeNull(); + expect(parseSlashSkillReference("/help")).toBeNull(); + }); + + it("returns null when text is not a leading slash command", () => { + expect(parseSlashSkillReference("hello /data-analysis")).toBeNull(); + expect(parseSlashSkillReference("/a/b")).toBeNull(); + expect(parseSlashSkillReference("plain text")).toBeNull(); + }); +}); + +describe("resolveSlashSkillDisplay", () => { + const skills = [makeSkill("data-analysis"), makeSkill("frontend-design")]; + + it("resolves when the referenced skill exists and is enabled", () => { + expect(resolveSlashSkillDisplay("/data-analysis go", skills)).toEqual({ + name: "data-analysis", + remainingText: "go", + }); + }); + + it("returns null for a slash command that is not an installed skill", () => { + expect(resolveSlashSkillDisplay("/hello world", skills)).toBeNull(); + expect(resolveSlashSkillDisplay("/unknown-skill do it", skills)).toBeNull(); + }); + + it("returns null when the skill exists but is disabled", () => { + expect( + resolveSlashSkillDisplay("/legacy-skill x", [ + makeSkill("legacy-skill", false), + ]), + ).toBeNull(); + }); +});