Files
owui-tools/tests/test_vm_tasks.py
T
2026-09-15 15:24:03 -04:00

162 lines
6.4 KiB
Python
Executable File

#!/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")