From 35e3c4ca45977abb0e0e7882ffcf055f7b8b48fe Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Sat, 18 Apr 2026 14:29:28 +0800 Subject: [PATCH 1/3] fix(opencode): expose skills in TUI slash command autocomplete Skills were returned with source='skill' from GET /command, but the OpenCode TUI autocomplete explicitly filters out source==='skill' commands (autocomplete.tsx:363). Native OpenCode's oh-my-openagent plugin injects skills with source='command', bypassing the filter. Changes: - Change skill command source from 'skill' to 'command' in GET /command endpoint (agent_routes.py) to match native OpenCode behavior - Expand Command model to match native SDK Command.Info type: add agent, model, subtask, hints fields; make description and source optional - Add _extract_hints() helper to extract / placeholders from skill templates, matching native Command.hints() utility - Fix server.py CommandStore update: replace broken add_commands() call with register_command(cmd, replace=True) loop - Add fallback skill lookup via pool.skill_commands in session_routes when CommandStore misses dynamically-added skills --- .../opencode_server/models/agent.py | 14 +++++++-- .../opencode_server/routes/agent_routes.py | 31 +++++++++++++++++-- .../opencode_server/routes/session_routes.py | 10 ++++++ .../opencode_server/server.py | 5 +-- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/agentpool_server/opencode_server/models/agent.py b/src/agentpool_server/opencode_server/models/agent.py index 8a493dcef..819a37790 100644 --- a/src/agentpool_server/opencode_server/models/agent.py +++ b/src/agentpool_server/opencode_server/models/agent.py @@ -45,14 +45,22 @@ class Agent(OpenCodeBaseModel): class Command(OpenCodeBaseModel): - """Slash command.""" + """Slash command matching OpenCode SDK Command.Info type.""" name: str - description: str = "" - source: Literal["command", "mcp", "skill"] = "command" + description: str | None = None + agent: str | None = None + """Target agent name for this command.""" + model: str | None = None + """Model identifier override for this command.""" + source: Literal["command", "mcp", "skill"] | None = "command" """Source of the command: built-in, MCP prompt, or skill.""" template: str = "" """Template content for skill commands (SKILL.md body).""" + subtask: bool = False + """Whether this command runs as a subtask.""" + hints: list[str] = Field(default_factory=list) + """Input hints extracted from template (e.g. $1, $2, $ARGUMENTS).""" class SkillInfo(OpenCodeBaseModel): diff --git a/src/agentpool_server/opencode_server/routes/agent_routes.py b/src/agentpool_server/opencode_server/routes/agent_routes.py index 9682221ba..1fa12237c 100644 --- a/src/agentpool_server/opencode_server/routes/agent_routes.py +++ b/src/agentpool_server/opencode_server/routes/agent_routes.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from typing import Any from fastapi import APIRouter, HTTPException @@ -45,6 +46,28 @@ logger = get_logger(__name__) +def _extract_hints(template: str) -> list[str]: + """Extract input hints from a command template. + + Matches the native OpenCode Command.hints() utility which finds + $N placeholders (e.g. $1, $2) and $ARGUMENTS. + + Args: + template: The command template string. + + Returns: + Sorted list of unique hint strings found in the template. + """ + hints: list[str] = [] + numbered = re.findall(r"\$\d+", template) + if numbered: + for match in sorted(set(numbered)): + hints.append(match) + if "$ARGUMENTS" in template: + hints.append("$ARGUMENTS") + return hints + + class AddMCPServerRequest(BaseModel): """Request to add an MCP server dynamically.""" @@ -184,7 +207,7 @@ async def list_commands(state: StateDep) -> list[Command]: try: prompts = await state.agent.tools.list_prompts() commands.extend([ - Command(name=p.name, description=p.description or "", source="mcp") for p in prompts + Command(name=p.name, description=p.description, source="mcp", hints=[]) for p in prompts ]) except Exception: pass @@ -219,8 +242,9 @@ async def list_commands(state: StateDep) -> list[Command]: Command( name=skill_cmd.name, description=skill_cmd.description, - source="skill", + source="command", template=template, + hints=_extract_hints(template), ) ) # Fallback: get skills directly from pool.skill_provider if skill_bridge not available @@ -238,8 +262,9 @@ async def list_commands(state: StateDep) -> list[Command]: Command( name=skill.name, description=skill.description, - source="skill", + source="command", template=template, + hints=_extract_hints(template), ) ) except Exception: diff --git a/src/agentpool_server/opencode_server/routes/session_routes.py b/src/agentpool_server/opencode_server/routes/session_routes.py index 70947fc08..65531bb20 100644 --- a/src/agentpool_server/opencode_server/routes/session_routes.py +++ b/src/agentpool_server/opencode_server/routes/session_routes.py @@ -1621,6 +1621,16 @@ async def execute_command( # noqa: PLR0915 ) return await _execute_slashed_command(state, session_id, request) + # Fallback: check pool.skill_commands directly when CommandStore misses + # This handles cases where skills were registered after CommandStore init + # or where the CommandStore sync callback hasn't fired yet + if state.pool.skill_commands and request.command in state.pool.skill_commands: + logger.debug( + "Command '%s' found in skill_commands but not CommandStore, executing as skill", + request.command, + ) + return await _execute_skill_command(state, session_id, request) + # Fall back to MCP prompts (existing code remains unchanged) prompts = await state.agent.tools.list_prompts() # Find matching prompt by name diff --git a/src/agentpool_server/opencode_server/server.py b/src/agentpool_server/opencode_server/server.py index 4a23e9749..1eee9256b 100644 --- a/src/agentpool_server/opencode_server/server.py +++ b/src/agentpool_server/opencode_server/server.py @@ -131,8 +131,9 @@ def create_app(*, agent: BaseAgent[Any, Any], working_dir: str | None = None) -> def update_command_store() -> None: """Update the CommandStore when skills change.""" if state.command_store is not None: - # Refresh commands in the store - state.command_store.add_commands(state.skill_bridge.get_commands()) + # Re-register all skill commands (replace=True to handle updates) + for cmd in state.skill_bridge.get_commands(): + state.command_store.register_command(cmd, replace=True) state.skill_bridge.on_commands_changed(update_command_store) From dda6e108ddc4753a805d1e9cad5580f0a328d53a Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Sat, 18 Apr 2026 14:48:05 +0800 Subject: [PATCH 2/3] fix(opencode): use numeric sort for numbered hints in _extract_hints Lexicographical sort produces $1, $10, $2 for double-digit placeholders; numeric sort yields the expected $1, $2, $10. --- src/agentpool_server/opencode_server/routes/agent_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agentpool_server/opencode_server/routes/agent_routes.py b/src/agentpool_server/opencode_server/routes/agent_routes.py index 1fa12237c..625ef4c3c 100644 --- a/src/agentpool_server/opencode_server/routes/agent_routes.py +++ b/src/agentpool_server/opencode_server/routes/agent_routes.py @@ -61,7 +61,7 @@ def _extract_hints(template: str) -> list[str]: hints: list[str] = [] numbered = re.findall(r"\$\d+", template) if numbered: - for match in sorted(set(numbered)): + for match in sorted(set(numbered), key=lambda x: int(x[1:])): hints.append(match) if "$ARGUMENTS" in template: hints.append("$ARGUMENTS") From 22ecfb74c40699fb1962a14661657a4b8d14f375 Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Sat, 18 Apr 2026 14:58:51 +0800 Subject: [PATCH 3/3] refactor(opencode): harden _extract_hints against None input and simplify with extend Accept Gemini Code Assist review suggestion: add early-return guard for None/empty template (defensive against provider bugs returning None) and replace for-append loop with extend for conciseness. Add test for None input case. --- .../opencode_server/routes/agent_routes.py | 9 +- .../test_command_model_and_hints.py | 199 ++++++++++++++++++ 2 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 tests/servers/opencode_server/test_command_model_and_hints.py diff --git a/src/agentpool_server/opencode_server/routes/agent_routes.py b/src/agentpool_server/opencode_server/routes/agent_routes.py index 625ef4c3c..f60ac4328 100644 --- a/src/agentpool_server/opencode_server/routes/agent_routes.py +++ b/src/agentpool_server/opencode_server/routes/agent_routes.py @@ -46,23 +46,24 @@ logger = get_logger(__name__) -def _extract_hints(template: str) -> list[str]: +def _extract_hints(template: str | None) -> list[str]: """Extract input hints from a command template. Matches the native OpenCode Command.hints() utility which finds $N placeholders (e.g. $1, $2) and $ARGUMENTS. Args: - template: The command template string. + template: The command template string. None is treated as empty. Returns: Sorted list of unique hint strings found in the template. """ + if not template: + return [] hints: list[str] = [] numbered = re.findall(r"\$\d+", template) if numbered: - for match in sorted(set(numbered), key=lambda x: int(x[1:])): - hints.append(match) + hints.extend(sorted(set(numbered), key=lambda x: int(x[1:]))) if "$ARGUMENTS" in template: hints.append("$ARGUMENTS") return hints diff --git a/tests/servers/opencode_server/test_command_model_and_hints.py b/tests/servers/opencode_server/test_command_model_and_hints.py new file mode 100644 index 000000000..096437518 --- /dev/null +++ b/tests/servers/opencode_server/test_command_model_and_hints.py @@ -0,0 +1,199 @@ +"""Tests for _extract_hints() and Command model. + +Unit tests for the skill autocomplete fix in AgentPool's OpenCode server. +""" + +from __future__ import annotations + +from agentpool_server.opencode_server.models.agent import Command +from agentpool_server.opencode_server.routes.agent_routes import _extract_hints + + +# ============================================================================= +# _extract_hints() tests +# ============================================================================= + + +def test_extract_hints_no_placeholders() -> None: + """Template with no placeholders returns empty list.""" + assert _extract_hints("Just a plain string") == [] + + +def test_extract_hints_empty_string() -> None: + """Empty string returns empty list.""" + assert _extract_hints("") == [] + + +def test_extract_hints_none_input() -> None: + """None input returns empty list (defensive against provider bugs).""" + assert _extract_hints(None) == [] + + +def test_extract_hints_single_numbered() -> None: + r"""Single $1 placeholder returns ["$1"].""" + assert _extract_hints("Analyze $1") == ["$1"] + + +def test_extract_hints_multiple_numbered() -> None: + r"""Multiple numbered placeholders $1 $2 $3 sorted numerically.""" + assert _extract_hints("Analyze $1 and $2 then $3") == ["$1", "$2", "$3"] + + +def test_extract_hints_arguments_placeholder() -> None: + r"""$ARGUMENTS placeholder returns ["$ARGUMENTS"].""" + assert _extract_hints("Process $ARGUMENTS") == ["$ARGUMENTS"] + + +def test_extract_hints_mixed_numbered_and_arguments() -> None: + r"""Mix of numbered and $ARGUMENTS: $1 $2 $ARGUMENTS.""" + assert _extract_hints("Analyze $1 with $2 using $ARGUMENTS") == [ + "$1", + "$2", + "$ARGUMENTS", + ] + + +def test_extract_hints_numeric_sort_not_lexicographic() -> None: + r"""Numeric sort: $1 $10 $2 → ["$1", "$2", "$10"], NOT lexicographic.""" + result = _extract_hints("$1 $10 $2") + assert result == ["$1", "$2", "$10"] + + +def test_extract_hints_deduplicates() -> None: + r"""Duplicate placeholders are deduplicated: $1 and $1 → ["$1"].""" + assert _extract_hints("$1 and $1") == ["$1"] + + +def test_extract_hints_out_of_order_sorted() -> None: + r"""Out-of-order placeholders are sorted: $3 $1 $2 → ["$1", "$2", "$3"].""" + assert _extract_hints("$3 $1 $2") == ["$1", "$2", "$3"] + + +def test_extract_hints_non_placeholder_dollars() -> None: + r"""Dollar signs that aren't placeholders ($foo, $NOTANUMBER) are ignored.""" + assert _extract_hints("$foo $NOTANUMBER") == [] + + +def test_extract_hints_lone_dollar() -> None: + r"""Just $ alone is not a placeholder.""" + assert _extract_hints("$") == [] + + +def test_extract_hints_adjacent_placeholders() -> None: + r"""Adjacent $1$2 are both extracted.""" + assert _extract_hints("$1$2") == ["$1", "$2"] + + +def test_extract_hints_only_arguments_no_numbered() -> None: + r"""Template with only $ARGUMENTS and no numbered placeholders.""" + assert _extract_hints("Run with $ARGUMENTS") == ["$ARGUMENTS"] + + +def test_extract_hints_large_numbers() -> None: + r"""Large numeric placeholders sorted correctly: $100 $2 $1 → ["$1", "$2", "$100"].""" + assert _extract_hints("$100 $2 $1") == ["$1", "$2", "$100"] + + +# ============================================================================= +# Command model tests +# ============================================================================= + + +def test_command_default_construction() -> None: + """Command with only name uses correct defaults.""" + cmd = Command(name="test") + assert cmd.name == "test" + assert cmd.description is None + assert cmd.source == "command" + assert cmd.template == "" + assert cmd.subtask is False + assert cmd.hints == [] + assert cmd.agent is None + assert cmd.model is None + + +def test_command_full_construction() -> None: + """Command with all fields set.""" + cmd = Command( + name="my-skill", + description="A great skill", + agent="coder", + model="openai:gpt-4o", + source="skill", + template="Analyze $1 and $ARGUMENTS", + subtask=True, + hints=["$1", "$ARGUMENTS"], + ) + assert cmd.name == "my-skill" + assert cmd.description == "A great skill" + assert cmd.agent == "coder" + assert cmd.model == "openai:gpt-4o" + assert cmd.source == "skill" + assert cmd.template == "Analyze $1 and $ARGUMENTS" + assert cmd.subtask is True + assert cmd.hints == ["$1", "$ARGUMENTS"] + + +def test_command_model_dump_includes_all_fields() -> None: + """model_dump() includes all fields (None values included).""" + cmd = Command(name="test") + data = cmd.model_dump() + assert "name" in data + assert "description" in data + assert "source" in data + assert "template" in data + assert "subtask" in data + assert "hints" in data + assert "agent" in data + assert "model" in data + + +def test_command_model_dump_exclude_none() -> None: + """model_dump(exclude_none=True) omits fields with None value.""" + cmd = Command(name="test") + data = cmd.model_dump(exclude_none=True) + assert "name" in data + assert "description" not in data + assert "agent" not in data + assert "model" not in data + # Non-None defaults are still present + assert "source" in data + assert "template" in data + assert "subtask" in data + assert "hints" in data + + +def test_command_source_literal_command() -> None: + """Source accepts 'command' literal.""" + cmd = Command(name="test", source="command") + assert cmd.source == "command" + + +def test_command_source_literal_mcp() -> None: + """Source accepts 'mcp' literal.""" + cmd = Command(name="test", source="mcp") + assert cmd.source == "mcp" + + +def test_command_source_literal_skill() -> None: + """Source accepts 'skill' literal.""" + cmd = Command(name="test", source="skill") + assert cmd.source == "skill" + + +def test_command_description_optional() -> None: + """Description is optional and defaults to None.""" + cmd = Command(name="test") + assert cmd.description is None + + +def test_command_source_defaults_to_command() -> None: + """Source defaults to 'command' when not provided.""" + cmd = Command(name="test") + assert cmd.source == "command" + + +def test_command_source_none_is_valid() -> None: + """Source can be explicitly set to None.""" + cmd = Command(name="test", source=None) + assert cmd.source is None