From e4724ea455963383a91733aed9d1b3384b75fe96 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 02:58:12 -0500 Subject: [PATCH] feat(skills): describe a /skill turn the way the user typed it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A /skill invocation expands into a message that embeds the whole skill body. Anything that summarizes a user turn from its raw content reads the skill's prose as if the user had written it. describe_skill_invocation() sits next to the existing extractor and reuses its markers, returning `/work — fix the title leak` for an invocation with an instruction and `/work` for a bare one. It also exports the SQL LIKE pattern and excerpt-joint sentinel that listing queries need to recognize scaffolding before a row reaches Python. --- agent/skill_commands.py | 50 ++++++ .../test_skill_invocation_description.py | 164 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 tests/agent/test_skill_invocation_description.py diff --git a/agent/skill_commands.py b/agent/skill_commands.py index fa1b4044a7ae..294ca2b17543 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -54,6 +54,21 @@ _BUNDLE_MARKER = " skill bundle," _BUNDLE_USER_INSTRUCTION = "\nUser instruction: " _BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the " +# The skill name sits in the first quoted span of the activation note, for both +# the single-skill and the bundle header ("work" / "/clean /work"). +_SKILL_NAME_RE = re.compile(re.escape(_SKILL_INVOCATION_PREFIX) + r'"([^"]*)"') + +# SQL LIKE pattern matching a skill-expanded turn, for listing queries that +# have to recognize scaffolding before the row reaches Python. The prefix +# contains no LIKE wildcards (`%`, `_`), so it needs no ESCAPE clause. +SKILL_SCAFFOLD_SQL_LIKE = _SKILL_INVOCATION_PREFIX + "%" + +# Marks where a preview query joined the head and tail of a long scaffolded +# message. ``describe_skill_invocation`` may hand back a span that runs across +# the joint (a bundle instruction cut off by the head window); callers cut the +# description there rather than show the skill body on the far side. +SKILL_EXCERPT_JOINT = "\x1e" + def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]: """Recover the user's instruction from a slash-skill-expanded turn. @@ -82,6 +97,41 @@ def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]: return None +def describe_skill_invocation(content: Any) -> Optional[str]: + """Render a slash-skill-expanded turn the way the user typed it. + + The expanded message embeds the whole skill body, so any surface that + summarizes a user turn from its raw content — session titles, sidebar + previews, the ``/rewind`` picker — otherwise shows the skill's own prose + as if the user had written it. That is how a skill's opening line ends up + as a session title. + + Returns ``"/work — fix the title leak"``, or ``"/work"`` for a bare + invocation, or ``None`` when *content* is not skill scaffolding (the + caller should then summarize it as an ordinary message). + """ + if not isinstance(content, str) or not content.startswith(_SKILL_INVOCATION_PREFIX): + return None + + match = _SKILL_NAME_RE.match(content) + name = (match.group(1) if match else "").strip() + # Bundle headers already carry their typed "/a /b" keys; a single skill is + # a bare name. + label = name if name.startswith("/") else f"/{name}" + + instruction = extract_user_instruction_from_skill_message(content) + if instruction and instruction is not content: + # An excerpted message (head + tail, joined by SKILL_EXCERPT_JOINT) can + # put the joint inside the matched span — keep only the side the + # instruction marker was found on. + instruction = instruction.split(SKILL_EXCERPT_JOINT)[0] + instruction = " ".join(instruction.split()) + if instruction: + return f"{label} — {instruction}" if name else instruction + + return label if name else None + + def _extract_single_skill_user_instruction(message: str) -> Optional[str]: # Single-skill format appends the user instruction after the skill body, so # the last occurrence is the user-provided one; the body may quote this text. diff --git a/tests/agent/test_skill_invocation_description.py b/tests/agent/test_skill_invocation_description.py new file mode 100644 index 000000000000..3b46e2f5bd06 --- /dev/null +++ b/tests/agent/test_skill_invocation_description.py @@ -0,0 +1,164 @@ +"""describe_skill_invocation() — recovering what the user typed from a /skill turn. + +A /skill invocation expands into a message that embeds the whole skill body. Any +surface that summarizes a user turn from its raw content (session titles, sidebar +previews, the /rewind picker) otherwise shows the SKILL's prose as if the user had +written it. + +Every case here builds the scaffolding with the real message builders rather than +a hand-written literal, so the description can't silently drift from the format it +parses. +""" + +import pytest + +import agent.skill_bundles as skill_bundles +import agent.skill_commands as skill_commands +import tools.skills_tool as skills_tool +from agent.skill_commands import ( + SKILL_EXCERPT_JOINT, + SKILL_SCAFFOLD_SQL_LIKE, + describe_skill_invocation, +) + +SKILL_BODY = "Kick off a task in a fresh isolated git worktree instead of the current checkout." + + +def _write_skill(skills_dir, name, body=SKILL_BODY): + skill_dir = skills_dir / name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: Description for {name}\n---\n\n# {name}\n\n{body}\n" + ) + return skill_dir + + +def _write_bundle(bundles_dir, slug, skills): + bundles_dir.mkdir(parents=True, exist_ok=True) + lines = [f"name: {slug}", "skills:"] + lines.extend(f" - {skill}" for skill in skills) + (bundles_dir / f"{slug}.yaml").write_text("\n".join(lines) + "\n") + + +@pytest.fixture() +def skills(tmp_path, monkeypatch): + """Install a 'work' skill and a 'demo' bundle, with caches reset.""" + skills_dir = tmp_path / "skills" + bundles_dir = tmp_path / "skill-bundles" + _write_skill(skills_dir, "work") + _write_skill(skills_dir, "clean", body="Polish your own diff by hand.") + _write_bundle(bundles_dir, "demo", ["work", "clean"]) + + monkeypatch.setattr(skills_tool, "SKILLS_DIR", skills_dir) + monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir)) + monkeypatch.setattr(skill_commands, "_skill_commands", {}) + monkeypatch.setattr(skill_commands, "_skill_commands_platform", None) + monkeypatch.setattr(skill_bundles, "_bundles_cache", {}) + monkeypatch.setattr(skill_bundles, "_bundles_cache_mtime", None) + skill_commands.scan_skill_commands() + skill_bundles.scan_bundles() + return skills_dir + + +class TestDescribeSkillInvocation: + def test_passes_through_a_normal_message(self): + assert describe_skill_invocation("fix the title leak") is None + + def test_ignores_non_string_content(self): + assert describe_skill_invocation(None) is None + assert describe_skill_invocation([{"type": "text", "text": "hi"}]) is None + + def test_recovers_the_typed_instruction(self, skills): + message = skill_commands.build_skill_invocation_message( + "/work", user_instruction="fix the title leak" + ) + assert describe_skill_invocation(message) == "/work — fix the title leak" + + def test_bare_invocation_describes_the_skill_alone(self, skills): + message = skill_commands.build_skill_invocation_message("/work") + assert describe_skill_invocation(message) == "/work" + + def test_never_surfaces_the_skill_body(self, skills): + message = skill_commands.build_skill_invocation_message( + "/work", user_instruction="fix the title leak" + ) + described = describe_skill_invocation(message) + assert "worktree" not in described + assert "IMPORTANT" not in described + + def test_runtime_note_is_not_part_of_the_instruction(self, skills): + message = skill_commands.build_skill_invocation_message( + "/work", + user_instruction="fix the title leak", + runtime_note="the runtime detail", + ) + assert describe_skill_invocation(message) == "/work — fix the title leak" + + def test_collapses_whitespace_in_a_multiline_instruction(self, skills): + message = skill_commands.build_skill_invocation_message( + "/work", user_instruction="fix the\n title\tleak" + ) + assert describe_skill_invocation(message) == "/work — fix the title leak" + + def test_bundle_carries_its_typed_keys(self, skills): + result = skill_bundles.build_bundle_invocation_message( + "/demo", user_instruction="fix the title leak" + ) + assert result is not None + message, _, _ = result + described = describe_skill_invocation(message) + assert described.endswith("— fix the title leak") + assert "worktree" not in described + + def test_stacked_skills_describe_every_typed_key(self, skills): + result = skill_commands.build_stacked_skill_invocation_message( + ["/work", "/clean"], user_instruction="fix the title leak" + ) + assert result is not None + message, _, _ = result + assert describe_skill_invocation(message) == "/work /clean — fix the title leak" + + +class TestExcerptedScaffolding: + """Preview queries hand over a head+tail excerpt, not the whole message.""" + + def _excerpt(self, message, window=400): + """Mirror what _preview_raw_select() hands to _shape_preview().""" + flat = message.replace("\n", " ").replace("\r", " ") + if len(message) <= window * 2: + return flat[: window * 2] + return flat[:window] + SKILL_EXCERPT_JOINT + flat[-window:] + + def test_excerpt_still_recovers_the_instruction(self, skills): + message = skill_commands.build_skill_invocation_message( + "/work", user_instruction="fix the title leak" + ) + described = describe_skill_invocation(self._excerpt(message)) + assert described == "/work — fix the title leak" + + def test_description_never_runs_across_the_joint(self, skills): + # A long body pushes the head window into the middle of the skill text; + # the instruction is only present on the tail side. + skill_md = skills / "work" / "SKILL.md" + skill_md.write_text(skill_md.read_text().replace(SKILL_BODY, "filler line.\n" * 200)) + skill_commands._skill_commands = {} + skill_commands._skill_commands_platform = None + skill_commands.scan_skill_commands() + message = skill_commands.build_skill_invocation_message( + "/work", user_instruction="fix the title leak" + ) + described = describe_skill_invocation(self._excerpt(message)) + assert SKILL_EXCERPT_JOINT not in described + assert "filler line" not in described + + +class TestSqlLikePattern: + def test_matches_the_prefix_the_builders_emit(self, skills): + message = skill_commands.build_skill_invocation_message("/work") + assert message.startswith(SKILL_SCAFFOLD_SQL_LIKE.rstrip("%")) + + def test_carries_no_like_wildcards_needing_escape(self): + # The pattern is interpolated into SQL without an ESCAPE clause, so the + # literal part must not contain '%' or '_'. + assert "%" not in SKILL_SCAFFOLD_SQL_LIKE[:-1] + assert "_" not in SKILL_SCAFFOLD_SQL_LIKE[:-1]