fix(cli): add spec-required Validation and Merge panels and correct title/message in agents session import #3460
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
|
||||
@@ -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