Files
nisse/nisse-dev/tests/test_tools.py
T
dongarandClaude Opus 5 d508e9a9b7
CI / checks (push) Successful in 43s
CI / checks (pull_request) Successful in 41s
Externalise plans to Gitea and accept approvals there (A4, Part H)
- approvals.py: PlanBoard opens one issue per project and posts each
  plan revision as a comment carrying its rev tag. The state store
  keeps only a pointer, so gate state can be re-derived from Gitea
  after a state-volume loss — external truth wins
- Gitea comments are a second approval source: revision binding, the
  typed word approve, and first-approval-wins work exactly as over
  transport, with a per-channel allowlist because a Gitea login is
  not a Matrix MXID
- Outcomes are echoed to the other channel, so the two records cannot
  silently disagree about what was approved
- A Gitea outage degrades durability of the record, not the ability
  to approve: planning proceeds and Matrix remains a complete channel
- Live test now asserts the server refuses the agent's own merge and
  its push to the protected branch. That property was previously
  true only because our code did not attempt it; branch protection
  makes it structural, and this test fails if the protection is ever
  relaxed
- Fixed a race in the process-group test: killing a group is
  asynchronous and the grandchild is an orphan, so its exit is not
  synchronised with our return; the test now waits for death

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:15:08 +00:00

137 lines
4.6 KiB
Python

import os
import time
from pathlib import Path
import pytest
from nisse_dev.tools import EXECUTOR_TOOLS, MAX_OUTPUT_CHARS, Workspace
@pytest.fixture
def ws(tmp_path: Path) -> Workspace:
return Workspace(tmp_path / "work", timeout_s=10)
def test_manifest_is_small_and_flat() -> None:
# A15: fewer tools, flatter arguments for the local executor.
names = [t["function"]["name"] for t in EXECUTOR_TOOLS]
assert names == ["read_file", "write_file", "list_dir", "run_command"]
for tool in EXECUTOR_TOOLS:
props = tool["function"]["parameters"]["properties"]
assert all(p["type"] == "string" for p in props.values())
def test_write_then_read_round_trip(ws: Workspace) -> None:
assert ws.write_file("src/app.py", "print('hi')\n").success
outcome = ws.read_file("src/app.py")
assert outcome.success
assert outcome.result == "print('hi')\n"
def test_relative_paths_land_in_the_workspace(ws: Workspace) -> None:
ws.write_file("notes.txt", "x")
assert (ws.root / "notes.txt").exists()
def test_read_missing_file_is_a_failed_result_not_an_exception(
ws: Workspace,
) -> None:
outcome = ws.read_file("nope.txt")
assert not outcome.success
assert "could not read" in outcome.result
def test_list_dir(ws: Workspace) -> None:
ws.write_file("a.txt", "a")
ws.write_file("sub/b.txt", "b")
outcome = ws.list_dir(".")
assert outcome.success
assert outcome.result.splitlines() == ["a.txt", "sub/"]
def test_list_empty_dir(ws: Workspace) -> None:
assert ws.list_dir(".").result == "(empty)"
def test_run_command_success_reports_exit_code_and_stdout(ws: Workspace) -> None:
outcome = ws.run_command("echo hello")
assert outcome.success
assert "exit code: 0" in outcome.result
assert "hello" in outcome.result
def test_nonzero_exit_is_a_result_not_an_error(ws: Workspace) -> None:
outcome = ws.run_command("exit 3")
assert not outcome.success # feeds the A12 failure count
assert "exit code: 3" in outcome.result
def test_command_runs_in_the_workspace(ws: Workspace) -> None:
ws.write_file("marker.txt", "here")
assert "marker.txt" in ws.run_command("ls").result
def test_command_timeout_is_reported(tmp_path: Path) -> None:
ws = Workspace(tmp_path / "work", timeout_s=1)
outcome = ws.run_command("sleep 30")
assert not outcome.success
assert "timed out" in outcome.result
def _is_running(pid: int) -> bool:
"""os.kill(pid, 0) is not enough: a killed orphan stays a zombie until
something reaps it, and no init does that inside a CI container. Read the
process state instead."""
try:
stat = Path(f"/proc/{pid}/stat").read_text()
except FileNotFoundError:
return False
# State is the first field after the parenthesised comm.
return stat.rsplit(")", 1)[1].split()[0] != "Z"
def test_timeout_kills_the_whole_process_group(tmp_path: Path) -> None:
ws = Workspace(tmp_path / "work", timeout_s=1)
pidfile = ws.root / "child.pid"
# A backgrounded grandchild must not outlive the timeout.
ws.run_command(f"(sleep 30 & echo $! > {pidfile}); sleep 30")
assert pidfile.exists()
pid = int(pidfile.read_text().strip())
assert _is_running(os.getpid()), "liveness check is broken, not the kill"
# SIGKILL to a process group is asynchronous, and the grandchild is an
# orphan by now, so its exit is not synchronised with our return. Wait
# for death rather than assuming it is instantaneous.
deadline = time.monotonic() + 5
while _is_running(pid) and time.monotonic() < deadline:
time.sleep(0.05)
assert not _is_running(pid)
def test_agent_credentials_are_not_inherited(
ws: Workspace, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("NISSE_LLM_TOKEN", "sk-secret")
monkeypatch.setenv("PATH", os.environ["PATH"])
outcome = ws.run_command("echo [${NISSE_LLM_TOKEN}]")
assert "sk-secret" not in outcome.result
assert "[]" in outcome.result
def test_stdin_is_closed_so_prompts_fail_fast(ws: Workspace) -> None:
outcome = ws.run_command("read line && echo got")
assert not outcome.success
assert "got" not in outcome.result
def test_long_output_is_truncated(ws: Workspace) -> None:
outcome = ws.run_command("printf 'x%.0s' $(seq 1 20000)")
assert len(outcome.result) < MAX_OUTPUT_CHARS + 200
assert "truncated" in outcome.result
def test_dispatch_unknown_tool_and_missing_argument(ws: Workspace) -> None:
assert not ws.dispatch("fly", {}).success
missing = ws.dispatch("read_file", {})
assert not missing.success
assert "missing argument" in missing.result