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
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:
@@ -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-*/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
-1
Submodule work/repo deleted from 435e409df9
Reference in New Issue
Block a user