mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-17 21:52:21 +02:00
215 lines
7.9 KiB
Python
215 lines
7.9 KiB
Python
import asyncio
|
|
import json
|
|
|
|
import pytest
|
|
|
|
import src.agent_loop as al
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"message",
|
|
[
|
|
"In /app/data/status.txt, change status=old to status=new.",
|
|
"In /app/data/status.txt, replace status=old with status=new.",
|
|
"Replace `June 30` with `July 1` in ./arrivals.md.",
|
|
"In data/fixtures/config.env, update MODE=dev to MODE=prod",
|
|
"Change ETA June 30 to ETA July 1 in ~/notes/arrival.txt",
|
|
"Replace stage=test with stage=production in ./deploy.env.",
|
|
'Replace "old value" with "new value" in "/tmp/my file.txt".',
|
|
'In fixture.py, change VALUE = "before" to VALUE = "after".',
|
|
],
|
|
)
|
|
def test_exact_file_replacement_classifier_accepts_direct_edits(message):
|
|
assert al._looks_like_exact_file_replacement(message) is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"message",
|
|
[
|
|
"Inspect /app/data/status.txt and fix the status.",
|
|
"Read ./arrivals.md first, then change June 30 to July 1.",
|
|
"Fix the bug in src/worker.py and run the tests.",
|
|
"Change the status in /app/data/status.txt.",
|
|
"Replace old with new.",
|
|
"Refactor src/worker.py to use the new scheduler.",
|
|
"Show the contents of /tmp/status.txt, then change old_value to new_value.",
|
|
"Open /tmp/status.txt and replace old_value with new_value.",
|
|
"Review /tmp/status.txt before changing old_value to new_value.",
|
|
"Use cat to inspect /tmp/status.txt, then replace old_value with new_value.",
|
|
"Examine /tmp/status.txt, then update old_value to new_value.",
|
|
"Look at /tmp/status.txt before replacing old_value with new_value.",
|
|
"Change old_value to new_value in /tmp/status.txt and verify the result.",
|
|
"Replace old_value with new_value in /tmp/status.txt, then run the tests.",
|
|
],
|
|
)
|
|
def test_exact_file_replacement_classifier_rejects_ambiguous_or_broad_work(message):
|
|
assert al._looks_like_exact_file_replacement(message) is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("message", "expected"),
|
|
[
|
|
(
|
|
"Read /tmp/status.txt first, then change old_value to new_value.",
|
|
{"path": "/tmp/status.txt", "old_string": "old_value", "new_string": "new_value"},
|
|
),
|
|
(
|
|
"Open /tmp/status.txt and replace old_value with new_value.",
|
|
{"path": "/tmp/status.txt", "old_string": "old_value", "new_string": "new_value"},
|
|
),
|
|
(
|
|
"Use cat to inspect ./status.txt, then replace `old value` with `new value`.",
|
|
{"path": "./status.txt", "old_string": "old value", "new_string": "new value"},
|
|
),
|
|
(
|
|
"Review /tmp/status.txt before changing old_value to new_value.",
|
|
{"path": "/tmp/status.txt", "old_string": "old_value", "new_string": "new_value"},
|
|
),
|
|
(
|
|
'Read fixture.py first, then change VALUE = "before" to VALUE = "after".',
|
|
{"path": "fixture.py", "old_string": 'VALUE = "before"', "new_string": 'VALUE = "after"'},
|
|
),
|
|
],
|
|
)
|
|
def test_inspection_file_replacement_extracts_explicit_mutation(message, expected):
|
|
assert al._parse_inspection_file_replacement(message) == expected
|
|
|
|
|
|
def test_inspection_file_replacement_rejects_broad_fix_request():
|
|
assert al._parse_inspection_file_replacement(
|
|
"Inspect /tmp/status.txt and fix the status."
|
|
) is None
|
|
|
|
|
|
def test_inspection_file_replacement_prefers_backtick_literals():
|
|
assert al._parse_inspection_file_replacement(
|
|
'Read fixture.py, replace the exact text `VALUE = "before"` '
|
|
'with `VALUE = "after"`, then verify it.'
|
|
) == {
|
|
"path": "fixture.py",
|
|
"old_string": 'VALUE = "before"',
|
|
"new_string": 'VALUE = "after"',
|
|
}
|
|
|
|
|
|
def test_semantic_inspection_edit_is_reconciled_to_exact_assignment():
|
|
edit = {
|
|
"path": "fixture.py",
|
|
"old_string": "VALUE from before",
|
|
"new_string": "after",
|
|
}
|
|
|
|
repaired = al._reconcile_inspection_edit_with_read(
|
|
edit,
|
|
'VALUE = "before"\n',
|
|
)
|
|
|
|
assert repaired == {
|
|
"path": "fixture.py",
|
|
"old_string": 'VALUE = "before"\n',
|
|
"new_string": 'VALUE = "after"\n',
|
|
}
|
|
|
|
|
|
def test_semantic_inspection_edit_does_not_guess_unrelated_source():
|
|
edit = {
|
|
"path": "fixture.py",
|
|
"old_string": "VALUE from before",
|
|
"new_string": "after",
|
|
}
|
|
|
|
assert al._reconcile_inspection_edit_with_read(edit, "OTHER = 'before'\n") == edit
|
|
|
|
|
|
def _run_agent(monkeypatch, *, message, workspace):
|
|
monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False)
|
|
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
|
monkeypatch.setattr(al, "estimate_tokens", lambda *args, **kwargs: 10, raising=False)
|
|
monkeypatch.setattr(al, "blocked_tools_for_owner", lambda owner: set(), raising=False)
|
|
|
|
calls = []
|
|
|
|
async def fake_stream(_candidates, messages, **kwargs):
|
|
calls.append({"messages": messages, "tools": kwargs.get("tools")})
|
|
yield "data: " + json.dumps({"delta": "No action"}) + "\n\n"
|
|
yield "data: [DONE]\n\n"
|
|
|
|
monkeypatch.setattr(al, "stream_llm_with_fallback", fake_stream, raising=False)
|
|
|
|
async def run():
|
|
stream = al.stream_agent_loop(
|
|
"https://api.openai.com/v1",
|
|
"gpt-test",
|
|
[{"role": "user", "content": message}],
|
|
max_rounds=2,
|
|
owner="pewds",
|
|
workspace=workspace,
|
|
relevant_tools={"edit_file", "read_file"},
|
|
)
|
|
return [chunk async for chunk in stream]
|
|
|
|
return calls, asyncio.run(run())
|
|
|
|
|
|
def test_exact_edit_is_dispatched_without_calling_model(monkeypatch, tmp_path):
|
|
path = tmp_path / "status.txt"
|
|
path.write_text("status=old\n")
|
|
message = f"In {path}, change status=old to status=new."
|
|
|
|
async def fake_execute(block, **_kwargs):
|
|
args = json.loads(block.content)
|
|
assert block.tool_type == "edit_file"
|
|
assert args == {"path": str(path), "old_string": "status=old", "new_string": "status=new"}
|
|
path.write_text("status=new\n")
|
|
return "edit_file: updated", {"output": f"Edited {path}", "exit_code": 0}
|
|
|
|
monkeypatch.setattr(al, "execute_tool_block", fake_execute, raising=False)
|
|
|
|
calls, chunks = _run_agent(monkeypatch, message=message, workspace=str(tmp_path))
|
|
events = [
|
|
json.loads(chunk[6:])
|
|
for chunk in chunks
|
|
if chunk.startswith("data: {")
|
|
]
|
|
|
|
assert calls == []
|
|
assert path.read_text() == "status=new\n"
|
|
assert [event.get("tool") for event in events if event.get("type") == "tool_start"] == ["edit_file"]
|
|
assert any(event.get("delta") == "Done." for event in events)
|
|
assert any(event.get("data", {}).get("direct_exact_file_edit") for event in events)
|
|
|
|
|
|
def test_exact_edit_failure_stops_without_model_or_shell(monkeypatch, tmp_path):
|
|
path = tmp_path / "status.txt"
|
|
path.write_text("status=other\n")
|
|
message = f"In {path}, change status=old to status=new."
|
|
|
|
async def fake_execute(block, **_kwargs):
|
|
assert block.tool_type == "edit_file"
|
|
return "edit_file: failed", {"error": "old_string was not found", "exit_code": 1}
|
|
|
|
monkeypatch.setattr(al, "execute_tool_block", fake_execute, raising=False)
|
|
|
|
calls, chunks = _run_agent(monkeypatch, message=message, workspace=str(tmp_path))
|
|
output = "".join(chunks)
|
|
|
|
assert calls == []
|
|
assert path.read_text() == "status=other\n"
|
|
assert "not found" in output
|
|
assert "clarify which occurrence" in output
|
|
|
|
|
|
def test_inspect_first_control_still_reaches_model(monkeypatch, tmp_path):
|
|
path = tmp_path / "status.txt"
|
|
path.write_text("status=old\n")
|
|
message = f"Read {path} first, then change status=old to status=new."
|
|
|
|
calls, _chunks = _run_agent(monkeypatch, message=message, workspace=str(tmp_path))
|
|
|
|
assert len(calls) == 1
|
|
assert path.read_text() == "status=old\n"
|
|
|
|
|
|
def test_edit_file_counts_as_effectful_work():
|
|
assert "edit_file" in al._VERIFIER_EFFECTFUL_TOOLS
|