#!/usr/bin/env python3 """Unit tests for the session task record and project-name validation. python3 tests/test_vm_tasks.py Pure Python: no VM, no container, no network. These two modules are the ones where a bug is silent rather than loud. A project name that slips through becomes a path, and a task gate that can be talked around gives the model back the freedom to declare itself finished - which is the specific failure the archived sub-agent stack was retired for. """ import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "vm", "vmd")) import project # noqa: E402 import tasks # noqa: E402 FAILS: list[str] = [] def check(label: str, condition: bool, detail: str = "") -> None: if condition: print(f"ok {label}") else: FAILS.append(f"{label}: {detail}") print(f"FAIL {label}\n {detail}") def raises(label: str, fn, exc) -> None: try: fn() except exc: print(f"ok {label}") return except Exception as other: # noqa: BLE001 FAILS.append(f"{label}: raised {type(other).__name__}, wanted {exc.__name__}") print(f"FAIL {label}\n raised {type(other).__name__}") return FAILS.append(f"{label}: did not raise") print(f"FAIL {label}\n did not raise") # --- project names ------------------------------------------------------ for good in ("ok-name", "a1", "x9", "a" * 31, "script-tests"): check(f"accepts {good!r}", project.validate(good) == good, "rejected") for bad in ("../gitea", "UPPER", "has space", "-lead", "x", "", "a" * 32, "dots.here", "under_score", "sub/dir", "name\n", "base", "trailing-"): raises(f"rejects {bad!r}", lambda b=bad: project.validate(b), project.ProjectError) # A rejection the model has to act on must say what to do instead. try: project.validate("../gitea") except project.ProjectError as exc: text = str(exc) check("rejection names the constraint", "lowercase" in text and "repository" in text, text) # --- the task gate ------------------------------------------------------ def fresh() -> tasks.TaskList: t = tasks.TaskList() t.plan(["write the script", "run it", "fix what it reports"]) return t raises("nothing works before plan_tasks", lambda: tasks.TaskList().update(1, "done"), tasks.TaskError) check("finish is refused before plan_tasks", tasks.TaskList().why_not_finished() is not None, "allowed") raises("a one-item plan is refused", lambda: tasks.TaskList().plan(["do the thing"]), tasks.TaskError) raises("a 21-item plan is refused", lambda: tasks.TaskList().plan([f"t{i}" for i in range(21)]), tasks.TaskError) raises("planning twice is refused", lambda: fresh().plan(["a", "b"]), tasks.TaskError) t = fresh() check("finish refused while tasks are open", t.why_not_finished() is not None, "allowed") check("the refusal lists what is open", "3 of 3 tasks are still open" in (t.why_not_finished() or ""), t.why_not_finished() or "") t.update(1, "done") t.update(2, "done") check("still refused with one open", t.why_not_finished() is not None, "allowed") raises("blocked without a reason is refused", lambda: t.update(3, "blocked"), tasks.TaskError) raises("dropped without a reason is refused", lambda: t.update(3, "dropped"), tasks.TaskError) raises("an unknown status is refused", lambda: t.update(3, "finished-ish", "x"), tasks.TaskError) raises("an unknown task id is refused", lambda: t.update(99, "done"), tasks.TaskError) t.update(3, "blocked", "the package is not in noble") check("finish allowed once everything is closed", t.why_not_finished() is None, t.why_not_finished() or "") check("the reason survives into the table", "the package is not in noble" in t.render(), t.render()) # --- added tasks stay visible ------------------------------------------ t2 = fresh() t2.add("check it on a clean boot", after_id=2) check("an added task is marked as added", "(added mid-session)" in t2.render(), t2.render()) check("an added task lands where asked", [x.text for x in t2.tasks][2] == "check it on a clean boot", [x.text for x in t2.tasks]) check("an added task is open, so it blocks finish", t2.why_not_finished() is not None, "allowed") # --- the status feed ---------------------------------------------------- t3 = fresh() check("status line before any progress", t3.status_line() == "0/3 · write the script", t3.status_line()) t3.update(1, "done") t3.update(2, "in_progress") check("status line follows the work", t3.status_line() == "1/3 · run it", t3.status_line()) # --- round trip --------------------------------------------------------- t4 = fresh() t4.update(1, "done") t4.add("extra", None) again = tasks.TaskList.from_json(t4.to_json()) check("survives a round trip", again.render() == t4.render(), again.render()) check("planned survives a round trip", again.planned is True, again.planned) # --- the list is the only thing the watcher can read -------------------- # # An agent that plans twelve tasks and then does the work in one long # stretch without touching the list leaves the person watching on "1/12" # for twenty minutes - measured - while the file grows and the status line # says nothing. The work was fine; the report of it was a flat line. # # Nudged in TOOL OUTPUT, because the prompt already asks and asking has not # worked. A nudge and not a refusal: being mid-task is legitimate, and a # tool that blocked on it would be wrong far more often than right. LOOP_SRC = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "vmagent", "app", "loop.py"), encoding="utf-8").read() check("a run of steps with no task movement is noticed", "TASK_DRIFT_LIMIT" in LOOP_SRC and "since_task" in LOOP_SRC) check("the three task verbs reset it, and nothing else does", 'TASK_VERBS = frozenset({"plan_tasks", "update_task", "add_task"})' in LOOP_SRC) after = LOOP_SRC.split("elif (since_task := since_task + 1) >= TASK_DRIFT_LIMIT:")[1] check("it nudges rather than refusing", "text +=" in after[:500] and "raise" not in after[:500], "blocking on this would be wrong more often than right") check("and the counter resets, so it does not fire on every step after", after.lstrip().startswith("since_task = 0")) print() print("=" * 60) if FAILS: print(f"FAILED {len(FAILS)}") for f in FAILS: print(" -", f) sys.exit(1) print("all vm task and project-name cases pass")