fix(cli): add spec-required Validation and Merge panels and correct title/message in agents session import #3460

Merged
freemo merged 2 commits from fix/session-import-missing-validation-merge-panels into master 2026-04-05 18:08:09 +00:00
11 changed files with 347 additions and 19 deletions
+17 -1
View File
@@ -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
+2 -1
View 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
+15 -8
View 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"
)
+4 -2
View File
@@ -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"
+55 -1
View File
@@ -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,
}
+6
View File
@@ -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}
+84
View File
@@ -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
+22 -5
View File
@@ -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}")
+38 -1
View File
@@ -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()]