fix(tui): resolve all PR #10649 review blockers
CI / push-validation (pull_request) Successful in 34s
CI / helm (pull_request) Successful in 48s
CI / build (pull_request) Successful in 52s
CI / quality (pull_request) Successful in 1m10s
CI / lint (pull_request) Failing after 1m14s
CI / typecheck (pull_request) Successful in 1m33s
CI / security (pull_request) Successful in 2m4s
CI / coverage (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 3m31s
CI / integration_tests (pull_request) Successful in 4m29s
CI / unit_tests (pull_request) Failing after 4m35s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 3s

Blocker 1: Remove accidentally committed git submodule 'work/repo'
and add 'work/' to .gitignore to prevent future inclusion.

Blocker 2: Add missing Behave step definitions for 'I switch to the
second session' and 'the app should still have exactly {count:d} session'.

Blocker 3: Fix deterministic session IDs — step_setup_sessions() now
uses 'sess-{i+1}' for non-first sessions instead of random UUIDs,
making scenarios that reference fixed IDs like 'sess-2' pass.

Blocker 4: Restore absolute path rejection in resolve_export_path() and
resolve_import_path() in PersonaRegistry (path traversal fix).

Blocker 5: Implement /session:create, /session:switch, /session:close,
/session:rename slash commands via a _SessionOps delegation layer.

Also replaces deprecated datetime.utcnow() with now(tz=timezone.utc)
This commit is contained in:
2026-05-08 20:08:10 +00:00
parent f407436979
commit f39e941ae8
5 changed files with 178 additions and 23 deletions
+3
View File
@@ -171,6 +171,9 @@ src/cleveragents/acp/
# Git worktrees for parallel task branches
worktrees/
# Scratch workspace directory (local dev only, never commit)
work/
*.bak
ca-cow-backup-*/
+20 -10
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone
from behave import given, then, when
@@ -39,15 +39,16 @@ def step_create_tui_app(context: object) -> None:
session_id="default",
transcript=[],
name="Default",
created_at=datetime.utcnow().isoformat(),
created_at=datetime.now(tz=timezone.utc).isoformat(),
)
]
context.app._active_session_index = 0 # type: ignore
@then("the app should have exactly {count:d} session")
@then("the app should still have exactly {count:d} session")
def step_check_session_count(context: object, count: int) -> None:
"""Check the number of sessions."""
"""Check the number of sessions (matches both "should have" and "should still have")."""
assert len(context.app._sessions) == count # type: ignore
@@ -75,7 +76,7 @@ def step_create_session(context: object, name: str) -> None:
session_id=session_id,
transcript=[],
name=name,
created_at=datetime.utcnow().isoformat(),
created_at=datetime.now(tz=timezone.utc).isoformat(),
)
context.app._sessions.append(new_session) # type: ignore
context.app._active_session_index = len(context.app._sessions) - 1 # type: ignore
@@ -91,7 +92,12 @@ def step_check_new_session_id(context: object) -> None:
@given("the TUI app has {count:d} session")
def step_setup_sessions(context: object, count: int) -> None:
"""Set up the TUI app with a specific number of sessions."""
"""Set up the TUI app with a specific number of sessions.
Session IDs are deterministic: ``"default"`` for index 0,
``"sess-{i+1}"`` for all subsequent indices, enabling scenarios
that reference fixed session IDs such as ``"sess-2"``.
"""
context.app = type("MockApp", (), {})() # type: ignore
context.app._sessions = [] # type: ignore
for i in range(count):
@@ -99,15 +105,13 @@ def step_setup_sessions(context: object, count: int) -> None:
session_id = "default"
name = "Default"
else:
import uuid
session_id = str(uuid.uuid4())[:8]
session_id = f"sess-{i + 1}"
name = f"Session {i + 1}"
session = SessionView(
session_id=session_id,
transcript=[],
name=name,
created_at=datetime.utcnow().isoformat(),
created_at=datetime.now(tz=timezone.utc).isoformat(),
)
context.app._sessions.append(session) # type: ignore
context.app._active_session_index = 0 # type: ignore
@@ -135,6 +139,12 @@ def step_switch_session(context: object, session_id: str) -> None:
raise ValueError(f"Session {session_id} not found")
@when("I switch to the second session")
def step_switch_to_second_session(context: object) -> None:
"""Switch to the second session (index 1)."""
context.app._active_session_index = 1 # type: ignore
@when("I close the session with session_id {session_id}")
def step_close_session(context: object, session_id: str) -> None:
"""Close a session."""
@@ -272,7 +282,7 @@ def step_create_new_session(context: object) -> None:
session_id=session_id,
transcript=[],
name=f"Session {len(context.app._sessions) + 1}", # type: ignore
created_at=datetime.utcnow().isoformat(),
created_at=datetime.now(tz=timezone.utc).isoformat(),
)
context.app._sessions.append(new_session) # type: ignore
context.app._active_session_index = len(context.app._sessions) - 1 # type: ignore
+113 -2
View File
@@ -17,6 +17,43 @@ from cleveragents.tui.persona.state import PersonaState
from cleveragents.tui.slash_catalog import SLASH_COMMAND_SPECS
# -- Session operation helpers -------------------------------------------------
class _SessionOps:
"""Delegates session CRUD calls to a TUI app instance."""
def __init__(self) -> None:
self._app: Any = None # CleverAgentsTuiApp (delayed binding)
def create_session(self, name: str = "") -> Any:
"""Create a new session, returning its SessionView or ``None``."""
if self._app is not None:
return self._app._create_session(name)
return None
@property
def sessions(self) -> list[Any]:
"""Return the full session list."""
if self._app is not None:
return self._app._list_sessions()
return []
@property
def active_session_id(self) -> str | None:
"""Return the active session ID."""
if self._app is not None and self._app._sessions: # type: ignore
s = self._app._sessions[self._app._active_session_index] # type: ignore
return s.session_id if self._app._active_session_index >= 0 else None
return None
@property
def session_count(self) -> int:
"""Return the number of sessions."""
if self._app is not None:
return len(self._app._sessions)
return 0
@dataclass(slots=True)
class TuiCommandRouter:
"""Slash command router used by the TUI prompt.
@@ -34,11 +71,20 @@ class TuiCommandRouter:
essential for test isolation under ``multiprocessing.fork()``
where ``unittest.mock.patch`` context managers do not propagate
to child workers.
session_ops:
Optional helper that delegates session CRUD calls into a TUI app
instance so slash commands like ``/session:create`` can modify
application state. Set via ``session_ops.bind(app)`` after the
app is created.
"""
persona_registry: PersonaRegistry
persona_state: PersonaState
container_factory: Callable[[], Any] | None = field(default=None, repr=False)
session_ops: _SessionOps = field(
default_factory=_SessionOps, # type: ignore[arg-type]
repr=False,
)
def _resolve_container(self) -> Any:
"""Return the DI container, using the injected factory or the global default."""
@@ -106,7 +152,63 @@ class TuiCommandRouter:
def _session_command(self, tokens: list[str], *, session_id: str) -> str:
if not tokens or tokens[0] == "show":
return f"Current session: {session_id}"
ops = self.session_ops
count = ops.session_count
active_id = ops.active_session_id or "unknown"
return (
f"Current session: {active_id} (ID={session_id}). "
f"Total sessions: {count}"
)
if tokens[0] == "create":
name = " ".join(tokens[1:]) if len(tokens) > 1 else ""
result = self.session_ops.create_session(name)
if result is not None:
return f"Session created: {result.name} ({result.session_id})"
return (
"Note: Session management requires the TUI app instance. "
"Use Ctrl+N in the TUI to create a session."
)
if tokens[0] == "switch":
if len(tokens) < 2:
return "Usage: /session:switch <session_id>"
target_id = tokens[1]
for i, session in enumerate(self.session_ops.sessions):
if session.session_id == target_id:
self.session_ops._app._active_session_index = i # type: ignore
return f"Switched to session: {target_id} ({session.name})"
return f"Session not found: {target_id}"
if tokens[0] == "close":
if len(tokens) < 2:
return "Usage: /session:close <session_id>"
close_id = tokens[1]
for i, session in enumerate(self.session_ops.sessions):
if session.session_id == close_id:
if len(self.session_ops.sessions) <= 1:
return "Cannot close the last session"
self.session_ops._app._sessions.pop(i) # type: ignore
if self.session_ops._app._active_session_index >= len( # type: ignore
self.session_ops._app._sessions, # type: ignore
):
self.session_ops._app._active_session_index = ( # type: ignore
len(self.session_ops._app._sessions) - 1 # type: ignore
)
return f"Session closed: {close_id}"
return f"Session not found: {close_id}"
if tokens[0] == "rename":
if len(tokens) < 3:
return "Usage: /session:rename <session_id> <new_name>"
rename_id = tokens[1]
new_name = " ".join(tokens[2:])
for session in self.session_ops.sessions:
if session.session_id == rename_id:
session.name = new_name # type: ignore
return f"Session renamed to: {new_name}"
return f"Session not found: {rename_id}"
if tokens[0] == "export":
return self._session_export(tokens[1:], session_id=session_id)
if tokens[0] == "import":
@@ -365,7 +467,11 @@ def run_tui(*, headless: bool = False, web: bool = False, web_port: int = 8000)
container = get_container()
registry = container.persona_registry()
state = container.persona_state(registry=registry)
router = TuiCommandRouter(persona_registry=registry, persona_state=state)
# Create app instance first so its nested session-management methods are
# accessible via the _SessionOps delegation layer.
ops = _SessionOps() # type: ignore[assignment]
router = TuiCommandRouter(persona_registry=registry, persona_state=state, session_ops=ops)
if headless:
payload = {
@@ -380,6 +486,11 @@ def run_tui(*, headless: bool = False, web: bool = False, web_port: int = 8000)
return 0
app = CleverAgentsTuiApp(command_router=router, persona_state=state)
# Bind the app's nested TUI instance into the ops delegation layer so
# /session:create, /session:switch, /session:close, /session:rename
# can modify application state directly.
inner = getattr(app, "_textual_clever_agents_tui_app_inner", None) or app
ops._app = inner # type: ignore[attr-defined]
if web:
return _run_tui_web(app, port=web_port)
+42 -10
View File
@@ -79,25 +79,57 @@ class PersonaRegistry:
return result
def resolve_export_path(self, output_path: Path) -> Path:
"""Resolve export path, accepting both absolute and relative paths."""
resolved = output_path.resolve()
# Allow absolute paths directly
"""Resolve export path, only allowing paths within the working directory.
Parameters
----------
output_path:
The requested output path (relative or absolute).
Returns
-------
The fully resolved path validated to be within the current working directory.
Raises
------
ValueError
If *output_path* is an absolute path or resolves outside the
current working directory.
"""
if output_path.is_absolute():
return resolved
# For relative paths, ensure they stay within working directory
raise ValueError(
"Export path must be relative to current working directory",
)
base = Path.cwd().resolve()
resolved = (base / output_path).resolve()
if not resolved.is_relative_to(base):
raise ValueError("Export path must stay within working directory")
return resolved
def resolve_import_path(self, input_path: Path) -> Path:
"""Resolve import path, accepting both absolute and relative paths."""
resolved = input_path.resolve()
# Allow absolute paths directly
"""Resolve import path, only allowing paths within the working directory.
Parameters
----------
input_path:
The requested input path (relative or absolute).
Returns
-------
The fully resolved path validated to be within the current working directory.
Raises
------
ValueError
If *input_path* is an absolute path or resolves outside the
current working directory.
"""
if input_path.is_absolute():
return resolved
# For relative paths, ensure they stay within working directory
raise ValueError(
"Import path must be relative to current working directory",
)
base = Path.cwd().resolve()
resolved = (base / input_path).resolve()
if not resolved.is_relative_to(base):
raise ValueError("Import path must stay within working directory")
return resolved
Submodule work/repo deleted from 435e409df9