2026-07-22 00:05:23 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import subprocess
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
Rename package, CLI, and deploy artifacts to rein-aharness (HARNESS-WP-0002-T02)
agent_harness -> rein_aharness (package + all imports), CLI command
agent-harness -> rein-aharness, Docker image tag, k8s namespace/labels/
names, Makefile targets, deploy script env var/paths. In-repo identity
strings (hub event source, metrics harness field, default assignee,
argparse prog name, commit author identity) updated to match.
Historical documents left untouched on purpose: docs/adr/ADR-001-agent-harness-architecture.md,
docs/architecture.md (dated v0.1 snapshot), workplans/HARNESS-WP-0001
(completed under the old name), and the SSH host alias
"forgejo-agent-harness" (external ~/.ssh/config entry, not owned here).
Verified: 47/47 tests pass, CLI runs correctly from a fresh venv,
`make image` builds and the resulting container runs correctly.
deploy/README.md gained an explicit rename cutover checklist for what
this session cannot safely do unattended -- moving the host-side
secrets dir and checkout on railiance01, and not deleting the old k8s
namespace until the new one is confirmed working. The actual live
cutover (running that checklist against the real Railiance deployment)
is not attempted here -- real production surgery on binky-control's
live automation, needs the operator present.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 14:22:18 +02:00
|
|
|
from rein_aharness import mail_triage
|
|
|
|
|
from rein_aharness.llm_connect_client import LLMConnectClient, LLMConnectError
|
2026-07-22 00:05:23 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _git_repo(tmp_path: Path) -> Path:
|
|
|
|
|
repo = tmp_path / "binky-control"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["git", "config", "user.email", "test@example.com"],
|
|
|
|
|
cwd=repo,
|
|
|
|
|
check=True,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
)
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["git", "config", "user.name", "test"],
|
|
|
|
|
cwd=repo,
|
|
|
|
|
check=True,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
)
|
|
|
|
|
(repo / "mailmeta" / "reports").mkdir(parents=True)
|
|
|
|
|
(repo / "mailmeta" / "mail-log.md").write_text(
|
|
|
|
|
"# Mail Log\n\n## E-mail triage log\n\n"
|
|
|
|
|
"| Date | Sender/Topic | Outcome |\n"
|
|
|
|
|
"|------|--------------|---------|\n"
|
|
|
|
|
"| 2026-07-01 | old@example.com: Old | ignore |\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
csv_path = repo / "mailmeta" / "reports" / "email-channel-evidence-report-test.csv"
|
|
|
|
|
csv_path.write_text(
|
|
|
|
|
"mailbox_received_at,source_from,source_subject,detected_message_class,"
|
|
|
|
|
"normalized_event_type,assessment_category,assessment_subclass,confidence\n"
|
|
|
|
|
"2026-07-21T10:00:00+00:00,stripe@stripe.com,Webhook failed,notification,"
|
|
|
|
|
"notification.fail,fail,fail.webhook,high\n"
|
|
|
|
|
"2026-07-21T11:00:00+00:00,evil@phish.example,Urgent wire,human_reply,"
|
|
|
|
|
"interaction.reply_received,undef,undef,low\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["git", "commit", "-m", "init"],
|
|
|
|
|
cwd=repo,
|
|
|
|
|
check=True,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
)
|
|
|
|
|
return repo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_triage_response_strips_fences() -> None:
|
|
|
|
|
text = """```json
|
|
|
|
|
{"log_entries": [{"date": "2026-07-21", "sender": "a", "subject": "b",
|
|
|
|
|
"action": "log", "outcome": "noted"}]}
|
|
|
|
|
```"""
|
|
|
|
|
entries = mail_triage.parse_triage_response(text)
|
|
|
|
|
assert len(entries) == 1
|
|
|
|
|
assert entries[0].action == "log"
|
|
|
|
|
assert entries[0].sender == "a"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_suspicious_forces_safe_outcome() -> None:
|
|
|
|
|
entries = mail_triage.parse_triage_response(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"log_entries": [
|
|
|
|
|
{
|
|
|
|
|
"date": "2026-07-21",
|
|
|
|
|
"sender": "evil@x",
|
|
|
|
|
"subject": "wire",
|
|
|
|
|
"action": "suspicious",
|
|
|
|
|
"outcome": "click link",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert "never acted" in entries[0].outcome.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apply_log_entries_inserts_rows(tmp_path: Path) -> None:
|
|
|
|
|
log = tmp_path / "mail-log.md"
|
|
|
|
|
log.write_text(
|
|
|
|
|
"## E-mail triage log\n\n"
|
|
|
|
|
"| Date | Sender/Topic | Outcome |\n"
|
|
|
|
|
"|------|--------------|---------|\n"
|
|
|
|
|
"| 2026-01-01 | old | x |\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
n = mail_triage.apply_log_entries(
|
|
|
|
|
log,
|
|
|
|
|
[
|
|
|
|
|
mail_triage.LogEntry(
|
|
|
|
|
"2026-07-21", "stripe", "Webhook", "log", "actionable"
|
|
|
|
|
),
|
|
|
|
|
mail_triage.LogEntry(
|
|
|
|
|
"2026-07-21", "noise", "sale", "ignore", "skip"
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
assert n == 1
|
|
|
|
|
text = log.read_text(encoding="utf-8")
|
|
|
|
|
assert "stripe" in text
|
|
|
|
|
assert "noise" not in text
|
|
|
|
|
# new row above old
|
|
|
|
|
assert text.index("2026-07-21") < text.index("2026-01-01")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_run_mail_triage_with_mock_complete(tmp_path: Path, monkeypatch) -> None:
|
|
|
|
|
repo = _git_repo(tmp_path)
|
|
|
|
|
monkeypatch.setattr(mail_triage.hub, "post_progress_event", lambda **kw: True)
|
|
|
|
|
|
|
|
|
|
def fake_complete(prompt: str) -> str:
|
|
|
|
|
assert "stripe@stripe.com" in prompt
|
|
|
|
|
assert "evil@phish.example" in prompt
|
|
|
|
|
return json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"log_entries": [
|
|
|
|
|
{
|
|
|
|
|
"date": "2026-07-21",
|
|
|
|
|
"sender": "stripe@stripe.com",
|
|
|
|
|
"subject": "Webhook failed",
|
|
|
|
|
"action": "queue",
|
|
|
|
|
"outcome": "Webhook failures — OH cleanup",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"date": "2026-07-21",
|
|
|
|
|
"sender": "evil@phish.example",
|
|
|
|
|
"subject": "Urgent wire",
|
|
|
|
|
"action": "suspicious",
|
|
|
|
|
"outcome": "external unknown",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
"notes": "2 items",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = mail_triage.run_mail_triage(
|
|
|
|
|
repo,
|
|
|
|
|
complete_fn=fake_complete,
|
|
|
|
|
report_to_hub=True,
|
|
|
|
|
commit=True,
|
|
|
|
|
)
|
|
|
|
|
assert result.ok
|
|
|
|
|
assert result.entries_applied == 2
|
|
|
|
|
assert result.committed
|
|
|
|
|
log = (repo / "mailmeta" / "mail-log.md").read_text(encoding="utf-8")
|
|
|
|
|
assert "Webhook" in log
|
|
|
|
|
assert "Suspicious" in log
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_run_mail_triage_no_report(tmp_path: Path, monkeypatch) -> None:
|
|
|
|
|
repo = tmp_path / "empty"
|
|
|
|
|
repo.mkdir()
|
|
|
|
|
(repo / "mailmeta" / "reports").mkdir(parents=True)
|
|
|
|
|
monkeypatch.setattr(mail_triage.hub, "post_progress_event", lambda **kw: True)
|
|
|
|
|
result = mail_triage.run_mail_triage(repo, complete_fn=lambda p: "{}")
|
|
|
|
|
assert not result.ok
|
|
|
|
|
assert "no CSV" in result.reason
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_llm_connect_client_complete(monkeypatch) -> None:
|
Rename package, CLI, and deploy artifacts to rein-aharness (HARNESS-WP-0002-T02)
agent_harness -> rein_aharness (package + all imports), CLI command
agent-harness -> rein-aharness, Docker image tag, k8s namespace/labels/
names, Makefile targets, deploy script env var/paths. In-repo identity
strings (hub event source, metrics harness field, default assignee,
argparse prog name, commit author identity) updated to match.
Historical documents left untouched on purpose: docs/adr/ADR-001-agent-harness-architecture.md,
docs/architecture.md (dated v0.1 snapshot), workplans/HARNESS-WP-0001
(completed under the old name), and the SSH host alias
"forgejo-agent-harness" (external ~/.ssh/config entry, not owned here).
Verified: 47/47 tests pass, CLI runs correctly from a fresh venv,
`make image` builds and the resulting container runs correctly.
deploy/README.md gained an explicit rename cutover checklist for what
this session cannot safely do unattended -- moving the host-side
secrets dir and checkout on railiance01, and not deleting the old k8s
namespace until the new one is confirmed working. The actual live
cutover (running that checklist against the real Railiance deployment)
is not attempted here -- real production surgery on binky-control's
live automation, needs the operator present.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 14:22:18 +02:00
|
|
|
import rein_aharness.llm_connect_client as mod
|
2026-07-22 00:05:23 +02:00
|
|
|
|
|
|
|
|
class FakeResp:
|
2026-08-22 23:55:29 +02:00
|
|
|
status_code = 200
|
|
|
|
|
|
2026-07-22 00:05:23 +02:00
|
|
|
def raise_for_status(self) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def json(self):
|
|
|
|
|
return {"content": "hello", "model": "test-model", "usage": {"total": 1}}
|
|
|
|
|
|
|
|
|
|
def fake_post(url, json=None, timeout=None): # noqa: A002
|
|
|
|
|
assert url.endswith("/execute")
|
|
|
|
|
assert "prompt" in json
|
|
|
|
|
return FakeResp()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(mod.httpx, "post", fake_post)
|
|
|
|
|
client = LLMConnectClient("http://llm.test", timeout_seconds=5)
|
|
|
|
|
out = client.complete("hi", model="m")
|
|
|
|
|
assert out == "hello"
|
|
|
|
|
assert client.last_response_metadata.get("model") == "test-model"
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 23:55:29 +02:00
|
|
|
class _ErrorResp:
|
|
|
|
|
def __init__(self, status_code: int, body) -> None:
|
|
|
|
|
self.status_code = status_code
|
|
|
|
|
self._body = body
|
|
|
|
|
|
|
|
|
|
def json(self):
|
|
|
|
|
if isinstance(self._body, Exception):
|
|
|
|
|
raise self._body
|
|
|
|
|
return self._body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_llm_connect_error_preserves_safe_provider_cause(monkeypatch) -> None:
|
|
|
|
|
import rein_aharness.llm_connect_client as mod
|
|
|
|
|
|
|
|
|
|
response = _ErrorResp(
|
|
|
|
|
502,
|
|
|
|
|
{
|
|
|
|
|
"error": "provider_api_error",
|
|
|
|
|
"provider_status": 401,
|
|
|
|
|
"provider": "openrouter",
|
|
|
|
|
"model": "model-1",
|
|
|
|
|
"message": "No auth credentials found",
|
|
|
|
|
"api_key": "sk-must-not-appear",
|
|
|
|
|
"raw_response": {"authorization": "Bearer secret"},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(mod.httpx, "post", lambda *args, **kwargs: response)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(LLMConnectError) as excinfo:
|
|
|
|
|
LLMConnectClient("http://llm.test").complete("hi")
|
|
|
|
|
|
|
|
|
|
text = str(excinfo.value)
|
|
|
|
|
assert "HTTP 502" in text
|
|
|
|
|
assert "error=provider_api_error" in text
|
|
|
|
|
assert "provider_status=401" in text
|
|
|
|
|
assert "provider=openrouter" in text
|
|
|
|
|
assert "model=model-1" in text
|
|
|
|
|
assert "message=No auth credentials found" in text
|
|
|
|
|
assert "sk-must-not-appear" not in text
|
|
|
|
|
assert "authorization" not in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_llm_connect_error_message_is_bounded(monkeypatch) -> None:
|
|
|
|
|
import rein_aharness.llm_connect_client as mod
|
|
|
|
|
|
|
|
|
|
response = _ErrorResp(502, {"error": "provider_api_error", "message": "x" * 5000})
|
|
|
|
|
monkeypatch.setattr(mod.httpx, "post", lambda *args, **kwargs: response)
|
|
|
|
|
with pytest.raises(LLMConnectError) as excinfo:
|
|
|
|
|
LLMConnectClient("http://llm.test").complete("hi")
|
|
|
|
|
assert len(str(excinfo.value)) < 600
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("body", [ValueError("not json"), ["unexpected"]])
|
|
|
|
|
def test_llm_connect_error_without_usable_body_reports_status(monkeypatch, body) -> None:
|
|
|
|
|
import rein_aharness.llm_connect_client as mod
|
|
|
|
|
|
|
|
|
|
response = _ErrorResp(504, body)
|
|
|
|
|
monkeypatch.setattr(mod.httpx, "post", lambda *args, **kwargs: response)
|
|
|
|
|
with pytest.raises(LLMConnectError, match="llm-connect returned HTTP 504"):
|
|
|
|
|
LLMConnectClient("http://llm.test").complete("hi")
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 00:05:23 +02:00
|
|
|
def test_get_client_requires_env(monkeypatch) -> None:
|
|
|
|
|
monkeypatch.delenv("LLM_CONNECT_URL", raising=False)
|
|
|
|
|
with pytest.raises(LLMConnectError, match="LLM_CONNECT_URL"):
|
Rename package, CLI, and deploy artifacts to rein-aharness (HARNESS-WP-0002-T02)
agent_harness -> rein_aharness (package + all imports), CLI command
agent-harness -> rein-aharness, Docker image tag, k8s namespace/labels/
names, Makefile targets, deploy script env var/paths. In-repo identity
strings (hub event source, metrics harness field, default assignee,
argparse prog name, commit author identity) updated to match.
Historical documents left untouched on purpose: docs/adr/ADR-001-agent-harness-architecture.md,
docs/architecture.md (dated v0.1 snapshot), workplans/HARNESS-WP-0001
(completed under the old name), and the SSH host alias
"forgejo-agent-harness" (external ~/.ssh/config entry, not owned here).
Verified: 47/47 tests pass, CLI runs correctly from a fresh venv,
`make image` builds and the resulting container runs correctly.
deploy/README.md gained an explicit rename cutover checklist for what
this session cannot safely do unattended -- moving the host-side
secrets dir and checkout on railiance01, and not deleting the old k8s
namespace until the new one is confirmed working. The actual live
cutover (running that checklist against the real Railiance deployment)
is not attempted here -- real production surgery on binky-control's
live automation, needs the operator present.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 14:22:18 +02:00
|
|
|
from rein_aharness.llm_connect_client import get_llm_connect_client
|
2026-07-22 00:05:23 +02:00
|
|
|
|
|
|
|
|
get_llm_connect_client()
|