forked from HAL9000/cleveragents-core
fix(tui): make /help command list all catalogued slash commands from SLASH_COMMAND_SPECS
Replace the hardcoded help string in TuiCommandRouter.handle() with a dynamic lookup against SLASH_COMMAND_SPECS from slash_catalog.py. Changes: - Add _help_command(), _help_list_all(), _help_for_command() methods to TuiCommandRouter - /help (no args): iterates SLASH_COMMAND_SPECS, groups commands by namespace (sorted alphabetically), renders all 70 commands with descriptions in colon-namespaced format (e.g. persona:list) - /help <command>: looks up the given command in SLASH_COMMAND_SPECS and renders its full help (group, description) - /help <unknown>: returns 'Unknown command: /<cmd>' message - /help /persona:list (with leading slash): strips the slash and resolves correctly - Import defaultdict and SLASH_COMMAND_SPECS at module level Tests: - Update tui_commands_coverage.feature: replace old exact-match scenario for help text with new dynamic-listing assertions - Add tui_commands_coverage_steps.py: new 'should contain' step definition - Add tui_help_command_full_catalog.feature: 12 BDD scenarios covering /help no-args, /help <command>, /help <unknown>, namespace grouping, colon-namespaced format, and regression against old hardcoded string - Add tui_help_command_full_catalog_steps.py: step definitions for the new feature (all-commands check, not-equal assertion) - Add robot/tui_help_command.robot: 5 Robot Framework integration tests verifying the help command via direct Python invocation and headless TUI startup Closes #3434 --- **Automated by CleverAgents Bot** Supervisor: Implementation | Agent: ca-issue-worker
This commit is contained in:
@@ -74,8 +74,8 @@ def step_router_with_mock_deps(context):
|
||||
context.registry = FakePersonaRegistry()
|
||||
context.state = FakePersonaState()
|
||||
context.router = TuiCommandRouter(
|
||||
persona_registry=context.registry,
|
||||
persona_state=context.state,
|
||||
persona_registry=context.registry, # type: ignore[arg-type]
|
||||
persona_state=context.state, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@@ -85,8 +85,8 @@ def step_router_with_set_support(context):
|
||||
context.registry = FakePersonaRegistry()
|
||||
context.state = FakePersonaState()
|
||||
context.router = TuiCommandRouter(
|
||||
persona_registry=context.registry,
|
||||
persona_state=context.state,
|
||||
persona_registry=context.registry, # type: ignore[arg-type]
|
||||
persona_state=context.state, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ def step_router_with_two_personas(context):
|
||||
)
|
||||
context.state = FakePersonaState()
|
||||
context.router = TuiCommandRouter(
|
||||
persona_registry=context.registry,
|
||||
persona_state=context.state,
|
||||
persona_registry=context.registry, # type: ignore[arg-type]
|
||||
persona_state=context.state, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@@ -109,8 +109,8 @@ def step_router_with_empty_registry(context):
|
||||
context.registry = FakePersonaRegistry(_personas=[])
|
||||
context.state = FakePersonaState()
|
||||
context.router = TuiCommandRouter(
|
||||
persona_registry=context.registry,
|
||||
persona_state=context.state,
|
||||
persona_registry=context.registry, # type: ignore[arg-type]
|
||||
persona_state=context.state, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@@ -148,6 +148,13 @@ def step_handle_result_starts_with(context, prefix):
|
||||
)
|
||||
|
||||
|
||||
@then('the handle result should contain "{substring}"')
|
||||
def step_handle_result_contains(context, substring):
|
||||
assert substring in context.handle_result, (
|
||||
f"Expected {substring!r} in result, got {context.handle_result!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_tui() headless scenario
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Step definitions for tui_help_command_full_catalog.feature.
|
||||
|
||||
Tests that TuiCommandRouter.handle('help') dynamically lists all commands
|
||||
from SLASH_COMMAND_SPECS and that /help <command> returns command-specific help.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import then
|
||||
|
||||
from cleveragents.tui.slash_catalog import SLASH_COMMAND_SPECS
|
||||
|
||||
|
||||
@then("the help result contains all 70 catalogued commands")
|
||||
def step_help_result_contains_all_commands(context: object) -> None:
|
||||
"""Verify that every command in SLASH_COMMAND_SPECS appears in the help output."""
|
||||
result: str = context.handle_result # type: ignore[attr-defined]
|
||||
missing = [
|
||||
spec.command for spec in SLASH_COMMAND_SPECS if spec.command not in result
|
||||
]
|
||||
assert not missing, (
|
||||
f"The following {len(missing)} commands were missing from /help output: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
@then('the handle result should not be "{unexpected}"')
|
||||
def step_handle_result_not_equal(context: object, unexpected: str) -> None:
|
||||
"""Verify the handle result is NOT the given string."""
|
||||
result: str = context.handle_result # type: ignore[attr-defined]
|
||||
assert result != unexpected, (
|
||||
f"Expected result to differ from {unexpected!r}, but got the same string"
|
||||
)
|
||||
@@ -27,10 +27,12 @@ Feature: TUI Command Router and run_tui coverage
|
||||
When I call handle with raw input "session show"
|
||||
Then the handle result should be "Current session: test-session"
|
||||
|
||||
Scenario: handle returns help text
|
||||
Scenario: handle returns help text listing all commands
|
||||
Given a TuiCommandRouter with a mock registry and state
|
||||
When I call handle with raw input "help"
|
||||
Then the handle result should be "Commands: /persona, /session, /help"
|
||||
Then the handle result should contain "Available slash commands:"
|
||||
And the handle result should contain "persona:list"
|
||||
And the handle result should contain "session:create"
|
||||
|
||||
Scenario: handle returns unknown command for unrecognised input
|
||||
Given a TuiCommandRouter with a mock registry and state
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
Feature: TUI /help command lists all catalogued slash commands
|
||||
The /help slash command must dynamically list all commands from
|
||||
SLASH_COMMAND_SPECS rather than returning a hardcoded string.
|
||||
It must also support /help <command> for command-specific help.
|
||||
|
||||
Background:
|
||||
Given the TUI commands module is imported
|
||||
And a TuiCommandRouter with a mock registry and state
|
||||
|
||||
# ---------- /help with no arguments ----------
|
||||
|
||||
Scenario: /help with no args returns header line
|
||||
When I call handle with raw input "help"
|
||||
Then the handle result should contain "Available slash commands:"
|
||||
|
||||
Scenario: /help with no args lists all 70 catalogued commands
|
||||
When I call handle with raw input "help"
|
||||
Then the help result contains all 70 catalogued commands
|
||||
|
||||
Scenario: /help with no args groups commands by namespace
|
||||
When I call handle with raw input "help"
|
||||
Then the handle result should contain "Session:"
|
||||
And the handle result should contain "Persona:"
|
||||
And the handle result should contain "Plan:"
|
||||
And the handle result should contain "Utility:"
|
||||
|
||||
Scenario: /help with no args uses colon-namespaced format
|
||||
When I call handle with raw input "help"
|
||||
Then the handle result should contain "persona:list"
|
||||
And the handle result should contain "session:create"
|
||||
And the handle result should contain "plan:rollback"
|
||||
And the handle result should contain "context:inspect"
|
||||
|
||||
Scenario: /help with no args does not contain old hardcoded listing
|
||||
When I call handle with raw input "help"
|
||||
Then the handle result should not be "Commands: /persona, /session, /help"
|
||||
|
||||
# ---------- /help <command> with a known command ----------
|
||||
|
||||
Scenario: /help persona:list returns command-specific help
|
||||
When I call handle with raw input "help persona:list"
|
||||
Then the handle result should contain "/persona:list"
|
||||
And the handle result should contain "Persona"
|
||||
And the handle result should contain "Display all personas"
|
||||
|
||||
Scenario: /help session:export returns command-specific help
|
||||
When I call handle with raw input "help session:export"
|
||||
Then the handle result should contain "/session:export"
|
||||
And the handle result should contain "Session"
|
||||
And the handle result should contain "Export session to JSON"
|
||||
|
||||
Scenario: /help help returns help command-specific help
|
||||
When I call handle with raw input "help help"
|
||||
Then the handle result should contain "/help"
|
||||
And the handle result should contain "Utility"
|
||||
And the handle result should contain "Show help"
|
||||
|
||||
Scenario: /help with leading slash on command name still works
|
||||
When I call handle with raw input "help /persona:set"
|
||||
Then the handle result should contain "/persona:set"
|
||||
And the handle result should contain "Switch active persona"
|
||||
|
||||
# ---------- /help <unknown> ----------
|
||||
|
||||
Scenario: /help with unknown command returns not-found message
|
||||
When I call handle with raw input "help nonexistent:command"
|
||||
Then the handle result should contain "Unknown command: /nonexistent:command"
|
||||
|
||||
Scenario: /help with completely unknown command returns not-found message
|
||||
When I call handle with raw input "help foobar"
|
||||
Then the handle result should contain "Unknown command: /foobar"
|
||||
@@ -0,0 +1,84 @@
|
||||
*** Settings ***
|
||||
Library Process
|
||||
Library String
|
||||
|
||||
*** Test Cases ***
|
||||
TUI Help Command Lists All Catalogued Commands
|
||||
[Documentation] /help with no args must list all commands from SLASH_COMMAND_SPECS,
|
||||
... not the old hardcoded 3-command string.
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... from cleveragents.tui.slash_catalog import SLASH_COMMAND_SPECS
|
||||
... from cleveragents.tui.commands import TuiCommandRouter
|
||||
... from unittest.mock import MagicMock
|
||||
... registry = MagicMock()
|
||||
... registry.list_personas.return_value = []
|
||||
... state = MagicMock()
|
||||
... router = TuiCommandRouter(persona_registry=registry, persona_state=state)
|
||||
... result = router.handle("help", session_id="default")
|
||||
... assert "Available slash commands:" in result, f"Missing header: {result[:200]}"
|
||||
... missing = [s.command for s in SLASH_COMMAND_SPECS if s.command not in result]
|
||||
... assert not missing, f"Missing commands: {missing}"
|
||||
... assert result != "Commands: /persona, /session, /help", "Old hardcoded string returned"
|
||||
... print("tui-help-all-commands-ok")
|
||||
${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} tui-help-all-commands-ok
|
||||
|
||||
TUI Help Command Groups By Namespace
|
||||
[Documentation] /help output must include group headers for Session, Persona, Plan, Utility.
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... from cleveragents.tui.commands import TuiCommandRouter
|
||||
... from unittest.mock import MagicMock
|
||||
... registry = MagicMock()
|
||||
... registry.list_personas.return_value = []
|
||||
... state = MagicMock()
|
||||
... router = TuiCommandRouter(persona_registry=registry, persona_state=state)
|
||||
... result = router.handle("help", session_id="default")
|
||||
... for group in ("Session:", "Persona:", "Plan:", "Utility:"):
|
||||
... assert group in result, f"Missing group header {group!r} in output"
|
||||
... print("tui-help-groups-ok")
|
||||
${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} tui-help-groups-ok
|
||||
|
||||
TUI Help Command With Known Command Returns Specific Help
|
||||
[Documentation] /help persona:list must return description for that specific command.
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... from cleveragents.tui.commands import TuiCommandRouter
|
||||
... from unittest.mock import MagicMock
|
||||
... registry = MagicMock()
|
||||
... registry.list_personas.return_value = []
|
||||
... state = MagicMock()
|
||||
... router = TuiCommandRouter(persona_registry=registry, persona_state=state)
|
||||
... result = router.handle("help persona:list", session_id="default")
|
||||
... assert "/persona:list" in result, f"Missing command name in: {result}"
|
||||
... assert "Display all personas" in result, f"Missing description in: {result}"
|
||||
... assert "Persona" in result, f"Missing group in: {result}"
|
||||
... print("tui-help-specific-ok")
|
||||
${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} tui-help-specific-ok
|
||||
|
||||
TUI Help Command With Unknown Command Returns Not Found
|
||||
[Documentation] /help nonexistent must return an "Unknown command" message.
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... from cleveragents.tui.commands import TuiCommandRouter
|
||||
... from unittest.mock import MagicMock
|
||||
... registry = MagicMock()
|
||||
... registry.list_personas.return_value = []
|
||||
... state = MagicMock()
|
||||
... router = TuiCommandRouter(persona_registry=registry, persona_state=state)
|
||||
... result = router.handle("help nonexistent:cmd", session_id="default")
|
||||
... assert "Unknown command" in result, f"Expected not-found message, got: {result}"
|
||||
... print("tui-help-unknown-ok")
|
||||
${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} tui-help-unknown-ok
|
||||
|
||||
TUI Headless Startup Help Payload Contains All Commands
|
||||
[Documentation] run_tui --headless JSON payload help field must list all commands.
|
||||
${result}= Run Process ${PYTHON} -m cleveragents tui --headless shell=False stderr=STDOUT env:CLEVERAGENTS_DATABASE_URL=sqlite:///:memory:
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} Available slash commands:
|
||||
Should Contain ${result.stdout} persona:list
|
||||
Should Contain ${result.stdout} session:create
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -12,6 +13,7 @@ from cleveragents.application.container import get_container
|
||||
from cleveragents.tui.app import CleverAgentsTuiApp, textual_available
|
||||
from cleveragents.tui.persona.registry import PersonaRegistry
|
||||
from cleveragents.tui.persona.state import PersonaState
|
||||
from cleveragents.tui.slash_catalog import SLASH_COMMAND_SPECS
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -52,9 +54,44 @@ class TuiCommandRouter:
|
||||
if tokens[0] == "session":
|
||||
return self._session_command(tokens[1:], session_id=session_id)
|
||||
if tokens[0] == "help":
|
||||
return "Commands: /persona, /session, /help"
|
||||
return self._help_command(tokens[1:])
|
||||
return f"Unknown command: /{raw}"
|
||||
|
||||
def _help_command(self, tokens: list[str]) -> str:
|
||||
"""Handle /help [command].
|
||||
|
||||
With no arguments, lists all catalogued slash commands grouped by
|
||||
namespace. With a command argument (e.g. ``/help persona:list``),
|
||||
renders command-specific help (description, usage, aliases).
|
||||
"""
|
||||
if not tokens:
|
||||
return self._help_list_all()
|
||||
return self._help_for_command(tokens[0])
|
||||
|
||||
def _help_list_all(self) -> str:
|
||||
"""Return a formatted listing of all commands grouped by namespace."""
|
||||
groups: dict[str, list[str]] = defaultdict(list)
|
||||
for spec in SLASH_COMMAND_SPECS:
|
||||
groups[spec.group].append(f" /{spec.command} — {spec.description}")
|
||||
lines: list[str] = ["Available slash commands:"]
|
||||
for group in sorted(groups):
|
||||
lines.append(f"\n{group}:")
|
||||
lines.extend(groups[group])
|
||||
return "\n".join(lines)
|
||||
|
||||
def _help_for_command(self, command: str) -> str:
|
||||
"""Return help text for a specific command, or a not-found message."""
|
||||
# Strip leading slash if the user typed e.g. /help /persona:list
|
||||
cmd = command.lstrip("/")
|
||||
for spec in SLASH_COMMAND_SPECS:
|
||||
if spec.command == cmd:
|
||||
return (
|
||||
f"/{spec.command}\n"
|
||||
f" Group: {spec.group}\n"
|
||||
f" Description: {spec.description}"
|
||||
)
|
||||
return f"Unknown command: /{cmd}"
|
||||
|
||||
def _persona_command(self, tokens: list[str], *, session_id: str) -> str:
|
||||
if not tokens or tokens[0] == "list":
|
||||
names = [persona.name for persona in self.persona_registry.list_personas()]
|
||||
|
||||
Reference in New Issue
Block a user