Files
cleveragents-core/robot/helper_tdd_skill_add_regression.py
freemo 93da31e80f
CI / build (pull_request) Successful in 25s
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 3m46s
CI / quality (pull_request) Successful in 4m9s
CI / security (pull_request) Successful in 4m13s
CI / typecheck (pull_request) Successful in 4m23s
CI / unit_tests (pull_request) Successful in 6m31s
CI / integration_tests (pull_request) Successful in 7m8s
CI / docker (pull_request) Successful in 1m14s
CI / e2e_tests (pull_request) Successful in 8m28s
CI / coverage (pull_request) Successful in 10m19s
CI / status-check (pull_request) Successful in 1s
CI / build (push) Successful in 24s
CI / lint (push) Successful in 3m36s
CI / quality (push) Successful in 3m41s
CI / typecheck (push) Successful in 3m56s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 4m4s
CI / integration_tests (push) Successful in 7m1s
CI / coverage (push) Failing after 12m22s
CI / e2e_tests (push) Failing after 16m19s
CI / unit_tests (push) Successful in 18m40s
CI / docker (push) Successful in 57s
CI / status-check (push) Failing after 3s
CI / benchmark-publish (push) Successful in 28m38s
CI / benchmark-regression (pull_request) Successful in 52m22s
fix(skill): resolve skill add persistence regression after PR #640
Root cause: _build_skill_service in container.py created a SkillRepository
pointing at the database but did not ensure the skills/skill_items tables
existed. When the tables were missing, SkillRepository.list_all() and
create() failed silently (caught by SkillService._load_from_db and
_persist_skill exception handlers), causing the service to operate in
in-memory-only mode. Skills added in one CLI process were lost when a
new process created a fresh SkillService.

Additionally, SkillRepository lacked auto_commit support. Each call to
session_factory() returned a new session, so the flush in create/update/
delete operated on a different session than the commit in
SkillService._commit(), meaning data was never actually persisted even
when the tables existed.

Fix:
1. Add targeted table creation in _build_skill_service (following the
   pattern in _build_session_service) — checks for missing skills and
   skill_items tables and creates them via Base.metadata.create_all.
2. Add auto_commit parameter to SkillRepository (following the pattern
   in SessionRepository) so each mutating method commits and closes its
   own session.
3. Pass auto_commit=True from the container builder.
4. Remove @tdd_expected_fail from TDD test (leaving @tdd_bug and
   @tdd_bug_980 as permanent regression guards).

ISSUES CLOSED: #980
2026-03-24 17:08:22 +00:00

224 lines
7.1 KiB
Python

"""Helper script for tdd_skill_add_regression.robot smoke tests.
Each subcommand exercises the real CLI code path (no mocks) to reproduce
bug #980: skills registered via ``agents skill add --config <file>`` in
one CLI invocation are not visible when ``agents skill list`` is run in
a separate CLI invocation.
The existing in-process persistence tests pass because they share an
in-memory SQLAlchemy session factory between two SkillService instances.
This helper uses ``reset_container()`` between invocations to simulate
the cross-process boundary — each CLI invocation starts with a clean
DI container and must load skill data from the on-disk database.
The helper reports the **real** outcome: it exits 0 and prints the
sentinel when the skill is found cross-process (bug is fixed), and
exits 1 when the skill is not found (bug still present). The
``tdd_expected_fail_listener`` on the Robot side handles pass/fail
inversion while the bug remains open.
"""
from __future__ import annotations
import os
import shutil
import sys
import tempfile
from collections.abc import Callable
from pathlib import Path
from typing import NoReturn
# Ensure local source tree is importable.
_ROOT = Path(__file__).resolve().parents[1]
_SRC = str(_ROOT / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from typer.testing import CliRunner # noqa: E402
from cleveragents.application.container import reset_container # noqa: E402
from cleveragents.cli.main import app as root_app # noqa: E402
runner = CliRunner()
_SKILL_YAML = """\
name: local/tdd-cross-process
description: "TDD cross-process persistence test skill"
tools:
- name: builtin/read_file
"""
_SKILL_NAME = "local/tdd-cross-process"
def _fail(message: str) -> NoReturn:
"""Print an error message to stderr and exit with code 1."""
print(message, file=sys.stderr)
sys.exit(1)
def _setup_env() -> tuple[str, str, dict[str, str | None]]:
"""Create a temp directory with a skill config and initialised database.
Returns:
Tuple of (tmpdir, config_path, saved_env_vars).
"""
tmpdir = tempfile.mkdtemp(prefix="tdd_skill_robot_xproc_")
# Write skill YAML config
config_path = os.path.join(tmpdir, "test-skill.yaml")
with open(config_path, "w") as fh:
fh.write(_SKILL_YAML)
# Database path
db_dir = os.path.join(tmpdir, ".cleveragents")
os.makedirs(db_dir, exist_ok=True)
db_url = f"sqlite:///{db_dir}/db.sqlite"
# Save and override env vars
saved_env: dict[str, str | None] = {}
for key in (
"CLEVERAGENTS_DATABASE_URL",
"CLEVERAGENTS_TEST_DATABASE_URL",
"CLEVERAGENTS_HOME",
):
saved_env[key] = os.environ.pop(key, None)
os.environ["CLEVERAGENTS_HOME"] = tmpdir
os.environ["CLEVERAGENTS_DATABASE_URL"] = db_url
# Initialise the project (creates database + migrations)
reset_container()
init_result = runner.invoke(
root_app, ["init", "--yes", "--force", "--path", tmpdir]
)
reset_container()
if init_result.exit_code != 0:
_restore_env(saved_env, tmpdir)
_fail(
f"Project init failed with exit code {init_result.exit_code}.\n"
f"Output: {init_result.output}"
)
return tmpdir, config_path, saved_env
def _restore_env(saved_env: dict[str, str | None], tmpdir: str) -> None:
"""Restore saved env vars and clean up temp directory."""
for key, val in saved_env.items():
if val is not None:
os.environ[key] = val
else:
os.environ.pop(key, None)
reset_container()
shutil.rmtree(tmpdir, ignore_errors=True)
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def cross_process_list() -> None:
"""Add a skill in one CLI invocation, list in another.
Simulates cross-process boundary by resetting the DI container
between invocations. Exits 0 with sentinel when the skill is
found (bug fixed). Exits 1 when not found (bug present).
"""
tmpdir, config_path, saved_env = _setup_env()
try:
# Invocation 1: add the skill
reset_container()
add_result = runner.invoke(
root_app,
["skill", "add", "--config", config_path, "--format", "json"],
)
if add_result.exit_code != 0:
_fail(
f"skill add failed with exit code {add_result.exit_code}.\n"
f"Output: {add_result.output}"
)
# Invocation 2: list skills (cross-process boundary)
reset_container()
list_result = runner.invoke(
root_app,
["skill", "list", "--format", "json"],
)
if _SKILL_NAME not in list_result.output:
_fail(
f"Skill '{_SKILL_NAME}' not found in cross-process list.\n"
f"Add output: {add_result.output}\n"
f"List exit code: {list_result.exit_code}\n"
f"List output: {list_result.output}"
)
print("tdd-skill-add-cross-process-list-ok")
finally:
_restore_env(saved_env, tmpdir)
def cross_process_show() -> None:
"""Add a skill in one CLI invocation, show in another.
Simulates cross-process boundary by resetting the DI container
between invocations. Exits 0 with sentinel when the skill show
succeeds (bug fixed). Exits 1 when not found (bug present).
"""
tmpdir, config_path, saved_env = _setup_env()
try:
# Invocation 1: add the skill
reset_container()
add_result = runner.invoke(
root_app,
["skill", "add", "--config", config_path, "--format", "json"],
)
if add_result.exit_code != 0:
_fail(
f"skill add failed with exit code {add_result.exit_code}.\n"
f"Output: {add_result.output}"
)
# Invocation 2: show the skill (cross-process boundary)
reset_container()
show_result = runner.invoke(
root_app,
["skill", "show", _SKILL_NAME, "--format", "json"],
)
if show_result.exit_code != 0 or _SKILL_NAME not in show_result.output:
_fail(
f"Skill '{_SKILL_NAME}' not found in cross-process show.\n"
f"Add output: {add_result.output}\n"
f"Show exit code: {show_result.exit_code}\n"
f"Show output: {show_result.output}"
)
print("tdd-skill-add-cross-process-show-ok")
finally:
_restore_env(saved_env, tmpdir)
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS: dict[str, Callable[[], None]] = {
"cross-process-list": cross_process_list,
"cross-process-show": cross_process_show,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(
f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>",
file=sys.stderr,
)
sys.exit(1)
cmd = _COMMANDS[sys.argv[1]]
cmd()