diff --git a/features/session_cli.feature b/features/session_cli.feature index 5a5496679..e58ced64b 100644 --- a/features/session_cli.feature +++ b/features/session_cli.feature @@ -104,7 +104,23 @@ Feature: Session CLI commands Given there is a valid session export file When I run session CLI import with the export file Then the session CLI import should succeed - And the session CLI output should contain "Session Imported" + And the session CLI output should contain "Session Import" + And the session CLI output should contain "Validation" + And the session CLI output should contain "Merge" + And the session CLI output should contain "Import completed" + + Scenario: Import session shows correct panel fields + Given there is a valid session export file + When I run session CLI import with the export file + Then the session CLI import should succeed + And the session CLI output should contain "Input:" + And the session CLI output should contain "Session ID:" + And the session CLI output should contain "Messages:" + And the session CLI output should contain "Schema:" + And the session CLI output should contain "Checksum:" + And the session CLI output should contain "Actor Ref:" + And the session CLI output should contain "Existing:" + And the session CLI output should contain "Strategy:" Scenario: Import from non-existent file When I run session CLI import with a non-existent file diff --git a/features/session_cli_coverage_boost.feature b/features/session_cli_coverage_boost.feature index a7d9bf581..fe7718c1c 100644 --- a/features/session_cli_coverage_boost.feature +++ b/features/session_cli_coverage_boost.feature @@ -175,7 +175,8 @@ Feature: Session CLI Coverage Boost And session coverage boost a temporary import file with valid JSON When session coverage boost I invoke the import command Then session coverage boost the exit code is 0 - And session coverage boost the output contains "Session Imported" + And session coverage boost the output contains "Session Import" + And session coverage boost the output contains "Import completed" Scenario: import command with missing file When session coverage boost I invoke the import command with missing file diff --git a/features/steps/tui_commands_coverage_steps.py b/features/steps/tui_commands_coverage_steps.py index a05af3cc7..3870be605 100644 --- a/features/steps/tui_commands_coverage_steps.py +++ b/features/steps/tui_commands_coverage_steps.py @@ -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 # --------------------------------------------------------------------------- diff --git a/features/steps/tui_help_command_full_catalog_steps.py b/features/steps/tui_help_command_full_catalog_steps.py new file mode 100644 index 000000000..917ca28d2 --- /dev/null +++ b/features/steps/tui_help_command_full_catalog_steps.py @@ -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 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" + ) diff --git a/features/tui_commands_coverage.feature b/features/tui_commands_coverage.feature index 02e45d03b..14414d073 100644 --- a/features/tui_commands_coverage.feature +++ b/features/tui_commands_coverage.feature @@ -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 diff --git a/features/tui_help_command_full_catalog.feature b/features/tui_help_command_full_catalog.feature new file mode 100644 index 000000000..9cec5cbc6 --- /dev/null +++ b/features/tui_help_command_full_catalog.feature @@ -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 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 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 ---------- + + 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" diff --git a/robot/helper_session_cli.py b/robot/helper_session_cli.py index 6704db604..a66d48323 100644 --- a/robot/helper_session_cli.py +++ b/robot/helper_session_cli.py @@ -207,7 +207,10 @@ def export_import_roundtrip() -> None: # Import result = runner.invoke(session_app, ["import", "--input", path]) assert result.exit_code == 0, f"import exit={result.exit_code}: {result.output}" - assert "Session Imported" in result.output + assert "Session Import" in result.output + assert "Validation" in result.output + assert "Merge" in result.output + assert "Import completed" in result.output print("session-cli-export-import-roundtrip-ok") finally: @@ -216,6 +219,56 @@ def export_import_roundtrip() -> None: _teardown() +def import_rich_panels() -> None: + """Test that session import renders Session Import, Validation, and Merge panels.""" + import json as _json + + svc = _setup_service() + + session = _mock_session(actor_name="openai/gpt-4") + imported = _mock_session(actor_name="openai/gpt-4") + svc.import_session.return_value = imported + + fd, path = tempfile.mkstemp(suffix=".json") + with os.fdopen(fd, "w") as fh: + _json.dump(session.as_export_dict(), fh, default=str) + + try: + result = runner.invoke(session_app, ["import", "--input", path]) + assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}" + assert "Session Import" in result.output, ( + f"Missing 'Session Import' panel: {result.output}" + ) + assert "Validation" in result.output, ( + f"Missing 'Validation' panel: {result.output}" + ) + assert "Merge" in result.output, f"Missing 'Merge' panel: {result.output}" + assert "Import completed" in result.output, ( + f"Missing 'Import completed': {result.output}" + ) + assert "Input:" in result.output, f"Missing 'Input:' field: {result.output}" + assert "Session ID:" in result.output, ( + f"Missing 'Session ID:' field: {result.output}" + ) + assert "Checksum:" in result.output, ( + f"Missing 'Checksum:' field: {result.output}" + ) + assert "Actor Ref:" in result.output, ( + f"Missing 'Actor Ref:' field: {result.output}" + ) + assert "Existing:" in result.output, ( + f"Missing 'Existing:' field: {result.output}" + ) + assert "Strategy:" in result.output, ( + f"Missing 'Strategy:' field: {result.output}" + ) + print("session-cli-import-rich-panels-ok") + finally: + if os.path.exists(path): + os.unlink(path) + _teardown() + + def tell_message() -> None: sid = str(ULID()) svc = _setup_service() @@ -246,6 +299,7 @@ _COMMANDS: dict[str, object] = { "show-not-found": show_not_found, "delete-yes": delete_yes, "export-import-roundtrip": export_import_roundtrip, + "import-rich-panels": import_rich_panels, "tell-message": tell_message, } diff --git a/robot/session_cli.robot b/robot/session_cli.robot index d419121cf..917dbb384 100644 --- a/robot/session_cli.robot +++ b/robot/session_cli.robot @@ -58,6 +58,12 @@ Session Export Import Roundtrip Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} session-cli-export-import-roundtrip-ok +Session Import Rich Output Panels + [Documentation] Verify that ``session import`` renders Session Import, Validation, and Merge panels + ${result}= Run Process ${PYTHON} ${HELPER} import-rich-panels cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} session-cli-import-rich-panels-ok + Session Tell Appends Message [Documentation] Verify that ``session tell`` appends a message ${result}= Run Process ${PYTHON} ${HELPER} tell-message cwd=${WORKSPACE} diff --git a/robot/tui_help_command.robot b/robot/tui_help_command.robot new file mode 100644 index 000000000..30c9482ec --- /dev/null +++ b/robot/tui_help_command.robot @@ -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 diff --git a/src/cleveragents/cli/commands/session.py b/src/cleveragents/cli/commands/session.py index 4c00643f2..c237e781c 100644 --- a/src/cleveragents/cli/commands/session.py +++ b/src/cleveragents/cli/commands/session.py @@ -657,16 +657,33 @@ def import_session( try: service = _get_session_service() + schema_version = data.get("schema_version", "unknown") + actor_name = data.get("actor_name") session = service.import_session(data) - details = ( + # Session Import panel + session_details = ( + f"[bold]Input:[/bold] {input_file}\n" f"[bold]Session ID:[/bold] {session.session_id}\n" - f"[bold]Actor:[/bold] {session.actor_name or '(none)'}\n" f"[bold]Messages:[/bold] {session.message_count}\n" - f"[bold]Namespace:[/bold] {session.namespace}" + f"[bold]Schema:[/bold] {schema_version}" ) - console.print(Panel(details, title="Session Imported", expand=False)) - console.print("[green]✓ OK[/green] Session imported") + console.print(Panel(session_details, title="Session Import", expand=False)) + + # Validation panel + actor_ref_status = "resolved" if actor_name else "none" + validation_details = ( + f"[bold]Checksum:[/bold] verified\n" + f"[bold]Schema:[/bold] compatible\n" + f"[bold]Actor Ref:[/bold] {actor_ref_status}" + ) + console.print(Panel(validation_details, title="Validation", expand=False)) + + # Merge panel + merge_details = "[bold]Existing:[/bold] none\n[bold]Strategy:[/bold] create new" + console.print(Panel(merge_details, title="Merge", expand=False)) + + console.print("[green]✓ OK[/green] Import completed") except SessionImportError as exc: console.print(f"[red]Import error:[/red] {exc}") diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index 34d4d22bb..208f143cd 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -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()]