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
41 changes: 41 additions & 0 deletions tests/test_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,28 @@ def create_message(self, payload, stream):
}


class FakeClientBashThenGenericFallbacks:
def __init__(self):
self.calls = 0

def create_message(self, payload, stream):
self.calls += 1
if self.calls == 1:
return {
"id": "1",
"role": "assistant",
"content": [
{"type": "tool_use", "id": "tool-1", "name": "Bash", "input": {"command": "pwd"}},
],
}
return {
"id": str(self.calls),
"role": "assistant",
"content": [{"type": "text", "text": "What would you like me to help you with?"}],
"stop_reason": "end_turn",
}


def test_loop_retries_on_empty_assistant_turn(tmp_path: Path):
client = FakeClientEmptyThenDone()
runner = Runner(client=client, repo=tmp_path, model="m", stream=False)
Expand Down Expand Up @@ -348,6 +370,25 @@ def test_loop_stops_after_retry_limit_on_empty_turns(tmp_path: Path):
assert result["response"]["content"] == []


def test_regular_runner_generic_reply_with_failing_verification_is_not_success(tmp_path: Path):
client = FakeClientBashThenGenericFallbacks()
runner = Runner(client=client, repo=tmp_path, model="m", stream=False)

def failing_verification(trigger="edit"):
runner._last_validation_summary = "status=fail; confidence=0.2"
if runner._mission_state is not None:
runner._mission_state.validation_failures = ["tests failing"]
return "<verification>\nstatus: fail\n</verification>"

runner._run_verification = failing_verification
out = runner.run("fix failing test")

assert client.calls == 3
execution = out.get("execution", {})
assert execution.get("terminated_reason") == "stalled_context_loss"
assert execution.get("completed") is False


class FakeClientDiffProposal:
def __init__(self):
self.calls = 0
Expand Down
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
146 changes: 146 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,132 @@ 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 "Objective:" in flattened
assert "Success:" in flattened
assert "Next action:" in flattened


def test_regular_trim_keeps_original_task_anchor_and_recent_tail() -> None:
messages = [
{"role": "system", "content": [{"type": "text", "text": "system-a"}]},
{"role": "system", "content": [{"type": "text", "text": "system-b"}]},
{"role": "user", "content": [{"type": "text", "text": "ORIGINAL TASK ANCHOR"}]},
]
for idx in range(6):
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 tail message"}]})
trimmed = state_runtime._trim_regular_turn_messages(messages, keep_units=3)
flattened = str(trimmed)
assert "system-a" in flattened and "system-b" in flattened
assert "ORIGINAL TASK ANCHOR" in flattened
assert "latest tail message" in flattened
state_runtime.validate_anthropic_tool_sequence(trimmed)


def test_prepare_messages_uses_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")
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["current_subgoal"] = "subgoal-from-memento"
payload["next_best_action"] = "memento-next-action"
payload["success_predicate"] = "memento-success"
payload["objective"] = "memento-objective"
payload["current_hypothesis"] = "memento-hypothesis"
payload["pinned_constraints"] = ["constraint-a"]
memento_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
prepared = state_runtime.prepare_messages_for_model(
runner, [{"role": "user", "content": [{"type": "text", "text": "do it"}]}]
)
flattened = str(prepared)
assert "Subgoal: subgoal-from-memento" in flattened
assert "Next action: memento-next-action" 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