Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions tests/test_bounded_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,18 @@ def test_villani_task_reports_completed_when_done(tmp_path: Path) -> None:

assert result["execution"]["completed"] is True
assert result["execution"]["terminated_reason"] == "completed"


def test_regular_runner_generic_no_tool_reply_with_unresolved_verification_does_not_complete(tmp_path: Path) -> None:
runner = _runner(
tmp_path,
[
{"role": "assistant", "content": [{"type": "text", "text": "What would you like me to help you with?"}]},
{"role": "assistant", "content": [{"type": "text", "text": "How would you like to proceed?"}]},
],
)

result = runner.run("repair failing tests")

assert result["execution"]["completed"] is False
assert result["execution"]["terminated_reason"] == "stalled_context_loss"
30 changes: 28 additions & 2 deletions tests/test_mission_state_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from villani_code.context_projection import build_model_context_packet
from villani_code.debug_bundle import create_debug_bundle
from villani_code.execution_memento import build_execution_memento, load_execution_memento
from villani_code.event_recorder import RuntimeEventRecorder
from villani_code.mission_state import (
MissionState,
Expand Down Expand Up @@ -34,13 +35,17 @@ def test_mission_state_roundtrip(tmp_path: Path) -> None:
mode="execution",
repo_root=str(tmp_path),
status="active",
last_memento_path="memo.json",
last_memento_turn_index=3,
verified_facts=[VerifiedFact(kind="k", value="v", source="s")],
open_hypotheses=[OpenHypothesis(hypothesis_id="h1", statement="maybe", confidence=0.4, status="open")],
)
save_mission_state(tmp_path, state)
loaded = load_mission_state(tmp_path, "m1")
assert loaded.verified_facts[0].value == "v"
assert loaded.open_hypotheses[0].hypothesis_id == "h1"
assert loaded.last_memento_path == "memo.json"
assert loaded.last_memento_turn_index == 3


def test_mission_directory_and_current_pointer(tmp_path: Path) -> None:
Expand Down Expand Up @@ -75,6 +80,17 @@ def test_transcript_save_updates_mission_state(tmp_path: Path) -> None:
path = runner._save_transcript_and_link({"requests": [], "responses": []})
loaded = load_mission_state(tmp_path, runner._mission_id)
assert loaded.last_transcript_path == str(path)
assert loaded.last_memento_path.endswith("execution_memento.json")


def test_execution_memento_is_saved_and_loadable(tmp_path: Path) -> None:
runner = Runner(client=DummyClient(), repo=tmp_path, model="x", stream=False, print_stream=False)
runner._ensure_mission("objective")
state = load_mission_state(tmp_path, runner._mission_id)
memento = load_execution_memento(tmp_path, runner._mission_id)
assert state.last_memento_path.endswith("execution_memento.json")
assert memento is not None
assert memento.objective == "objective"


def test_plan_artifact_serialization_roundtrip() -> None:
Expand Down Expand Up @@ -131,6 +147,16 @@ def test_context_projection_excludes_runtime_artifacts(tmp_path: Path) -> None:
assert packet["intended_targets"] == ["tests/test_app.py"]


def test_execution_memento_excludes_runtime_artifacts(tmp_path: Path) -> None:
runner = Runner(client=DummyClient(), repo=tmp_path, model="x", stream=False, print_stream=False)
runner._ensure_mission("do x")
runner._mission_state.changed_files = ["src/app.py", ".villani_code/missions/m1/state.json"]
runner._mission_state.intended_targets = ["./.villani_code/sessions/last.json", "tests/test_app.py"]
memento = build_execution_memento(runner)
assert memento.changed_files == ["src/app.py"]
assert memento.in_scope_files == ["tests/test_app.py"]


def test_projected_context_not_injected_on_initial_turn_even_when_requested(tmp_path: Path) -> None:
runner = Runner(client=DummyClient(), repo=tmp_path, model="x", stream=False, print_stream=False)
runner._ensure_mission("objective")
Expand Down Expand Up @@ -179,7 +205,7 @@ def test_projected_context_injected_into_latest_safe_user_turn(tmp_path: Path) -
runner._inject_projected_context(messages)
assert messages[0]["content"][0]["text"] == "older prompt"
assert messages[2]["content"] == [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}]
assert "Mission context packet:" in messages[3]["content"][0]["text"]
assert "EXECUTION STATE" in messages[3]["content"][0]["text"]


def test_projected_context_injected_into_string_user_content(tmp_path: Path) -> None:
Expand All @@ -191,7 +217,7 @@ def test_projected_context_injected_into_string_user_content(tmp_path: Path) ->
]
runner._inject_projected_context(messages)
assert isinstance(messages[1]["content"], str)
assert "Mission context packet:" in messages[1]["content"]
assert "EXECUTION STATE" in messages[1]["content"]
assert messages[1]["content"].endswith("final prompt")


Expand Down
133 changes: 133 additions & 0 deletions tests/test_state_runtime.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
from pathlib import Path
from types import SimpleNamespace

Expand All @@ -14,6 +15,25 @@ def create_message(self, _payload, stream):
return {"content": [{"type": "text", "text": "ok"}]}


class _Inventory:
def __init__(self) -> None:
self.task_id = ""


class _GovernanceStub:
def load_inventory(self):
return _Inventory()

def register_item(self, *args, **kwargs):
return None

def prune_for_budget(self, *args, **kwargs):
return None

def save_inventory(self, *args, **kwargs):
return None


def _seed_repo(repo: Path) -> None:
(repo / "villani_code").mkdir(parents=True, exist_ok=True)
(repo / "villani_code" / "__init__.py").write_text("", encoding="utf-8")
Expand Down Expand Up @@ -147,6 +167,119 @@ def test_validate_anthropic_tool_sequence_rejects_non_user_followup() -> None:
state_runtime.validate_anthropic_tool_sequence(messages)


def test_prepare_messages_for_regular_runner_injects_execution_memento(tmp_path: Path) -> None:
_seed_repo(tmp_path)
runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, small_model=False)
runner._context_governance = _GovernanceStub()
runner._ensure_mission("fix parser")
messages = [
{"role": "system", "content": [{"type": "text", "text": "sys"}]},
{"role": "user", "content": [{"type": "text", "text": "do it"}]},
]
prepared = state_runtime.prepare_messages_for_model(runner, messages)
flattened = str(prepared)
assert "EXECUTION STATE" in flattened
assert "Mission context packet:" not in flattened


def test_prepare_messages_for_model_does_not_duplicate_memento_injection(tmp_path: Path) -> None:
_seed_repo(tmp_path)
runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, small_model=False)
runner._context_governance = _GovernanceStub()
runner._ensure_mission("fix parser")
messages = [{"role": "user", "content": [{"type": "text", "text": "do it"}]}]
first = state_runtime.prepare_messages_for_model(runner, messages)
second = state_runtime.prepare_messages_for_model(runner, first)
count = str(second).count("EXECUTION STATE")
assert count == 1


def test_prepare_messages_for_model_regular_trim_keeps_tool_sequence_integrity(tmp_path: Path) -> None:
_seed_repo(tmp_path)
runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, small_model=False)
runner._context_governance = _GovernanceStub()
runner._ensure_mission("fix parser")
messages = [{"role": "system", "content": [{"type": "text", "text": "sys"}]}]
for idx in range(8):
messages.extend(
[
{"role": "assistant", "content": [{"type": "tool_use", "id": f"t{idx}", "name": "Read", "input": {"file_path": "a.py"}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": f"t{idx}", "content": "ok", "is_error": False}]},
]
)
messages.append({"role": "user", "content": [{"type": "text", "text": "latest task"}]})
prepared = state_runtime.prepare_messages_for_model(runner, messages)
assert len(prepared) < len(messages)
state_runtime.validate_anthropic_tool_sequence(prepared)


def test_prepare_messages_falls_back_when_memento_required_fields_missing(tmp_path: Path) -> None:
_seed_repo(tmp_path)
runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, small_model=False)
runner._context_governance = _GovernanceStub()
runner._ensure_mission("fix parser")
if runner._mission_state is not None:
memento_path = tmp_path / ".villani_code" / "missions" / runner._mission_state.mission_id / "execution_memento.json"
payload = json.loads(memento_path.read_text(encoding="utf-8"))
payload["objective"] = ""
memento_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
messages = [{"role": "user", "content": [{"type": "text", "text": "do it"}]}]
prepared = state_runtime.prepare_messages_for_model(runner, messages)
flattened = str(prepared)
assert "EXECUTION STATE" in flattened
assert "Objective:" in flattened
assert "Success:" in flattened
assert "Next action:" in flattened


def test_prepare_messages_falls_back_when_memento_missing(tmp_path: Path) -> None:
_seed_repo(tmp_path)
runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, small_model=False)
runner._context_governance = _GovernanceStub()
runner._ensure_mission("fix parser")
if runner._mission_state is not None:
memento_path = tmp_path / ".villani_code" / "missions" / runner._mission_state.mission_id / "execution_memento.json"
memento_path.unlink()
messages = [{"role": "user", "content": [{"type": "text", "text": "do it"}]}]
prepared = state_runtime.prepare_messages_for_model(runner, messages)
flattened = str(prepared)
assert "EXECUTION STATE" in flattened
assert "Objective:" in flattened
assert "Success:" in flattened
assert "Next action:" in flattened


def test_trim_regular_turn_messages_keeps_task_anchor_and_recent_tail() -> None:
messages = [
{"role": "system", "content": [{"type": "text", "text": "sys"}]},
{"role": "user", "content": [{"type": "text", "text": "ORIGINAL TASK"}]},
{"role": "assistant", "content": [{"type": "text", "text": "ack"}]},
{"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "Read", "input": {"file_path": "a.py"}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}]},
{"role": "assistant", "content": [{"type": "tool_use", "id": "t2", "name": "Read", "input": {"file_path": "b.py"}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t2", "content": "ok"}]},
{"role": "user", "content": [{"type": "text", "text": "latest user followup"}]},
]
trimmed = state_runtime._trim_regular_turn_messages(messages, keep_units=2)
flattened = str(trimmed)
assert "sys" in flattened
assert "ORIGINAL TASK" in flattened
assert "latest user followup" in flattened
state_runtime.validate_anthropic_tool_sequence(trimmed)


def test_prepare_messages_uses_valid_saved_memento_over_fallback(tmp_path: Path) -> None:
_seed_repo(tmp_path)
runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, small_model=False)
runner._context_governance = _GovernanceStub()
runner._ensure_mission("fix parser")
messages = [{"role": "user", "content": [{"type": "text", "text": "do it"}]}]
prepared = state_runtime.prepare_messages_for_model(runner, messages)
flattened = str(prepared)
assert "Subgoal:" in flattened
assert "Why next:" in flattened


def test_run_verification_targets_touched_tests(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_seed_repo(tmp_path)
(tmp_path / "tests").mkdir(exist_ok=True)
Expand Down
Loading
Loading