Files
odysseus/tests/test_required_read_operation.py
T

229 lines
10 KiB
Python

import ast
import asyncio
import json
import time
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType, SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import src.agent_loop as al
@dataclass(frozen=True)
class Operation:
tool_name: str
args: object
max_items: int | None = None
def contract(operation, *, permits=True):
return SimpleNamespace(required_operation=operation, capabilities={"notes"},
permits=lambda _: permits)
def notes_operation(limit=3):
return Operation("manage_notes", MappingProxyType({"action": "list"}), limit)
def notes_result():
return {"output": "Found 30 notes:\n" + "\n".join(
f"- [note{i}] **Title {i}**" for i in range(30)), "exit_code": 0}
def test_absent_field_preserves_legacy_behavior():
assert al._required_safe_read_operation(None) is None
assert al._required_safe_read_operation(SimpleNamespace()) is None
def test_actual_contract_read_operation_field_is_consumed():
from src.turn_contract import resolve_turn_contract
from src.tool_policy import ToolPolicy
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
resolved = resolve_turn_contract(
capabilities={"notes"}, schemas=FUNCTION_TOOL_SCHEMAS,
policy=ToolPolicy(), message="Show my first 3 notes",
)
block, limit = al._required_safe_read_operation(resolved)
assert block.tool_type == "manage_notes"
assert json.loads(block.content) == {"action": "list"}
assert limit == 3
@pytest.mark.parametrize("name,args", [
("manage_notes", {"action": "add", "title": "Do not create"}),
("manage_notes", {}), ("manage_calendar", {"action": "delete"}),
("web_search", {"query": "IANA"}), ("bash", {"command": "true"}),
("read_file", {"path": "/workspace/test"}),
("list_emails", {}), ("mcp__email__send_email", {}),
("mcp__other__list_email_accounts", {}),
])
def test_recovery_never_acquires_mutation_search_or_shell_scope(name, args):
assert al._required_safe_read_operation(contract(Operation(name, args))) is None
def test_operation_requires_permission_and_valid_shape():
assert al._required_safe_read_operation(contract(notes_operation(), permits=False)) is None
assert al._required_safe_read_operation(contract(Operation("manage_notes", "list"))) is None
assert al._required_safe_read_operation(contract(Operation("manage_notes", {"action": "list"}, -1))) is None
compound = contract(notes_operation())
compound.capabilities.add("calendar")
assert al._required_safe_read_operation(compound) is None
resolved = al._required_safe_read_operation(contract({
"tool_name": "manage_notes", "args": {"action": "list"}, "max_items": 2}))
assert resolved[1] == 2
def test_warm_offers_do_not_disable_a_single_active_required_read():
sealed = contract(notes_operation())
sealed.capabilities = {"notes", "calendar", "email"}
sealed.active_capabilities = frozenset({"notes"})
resolved = al._required_safe_read_operation(sealed)
assert resolved is not None
assert resolved[0].tool_type == "manage_notes"
def test_compound_active_request_still_disables_terminal_read_recovery():
sealed = contract(notes_operation())
sealed.capabilities = {"notes", "calendar"}
sealed.active_capabilities = frozenset({"notes", "calendar"})
assert al._required_safe_read_operation(sealed) is None
def test_limit_removes_hidden_note_payload_and_preserves_anchors():
block, limit = al._required_safe_read_operation(contract(notes_operation()))
answer = al._required_read_summary(block, notes_result(), limit)
assert answer.count("#note-") == 3
assert "Title 3]" not in answer
assert "ody-more" not in answer
assert "[Title 0](#note-note0)" in answer
def test_registry_clean_raw_fallback_is_nonempty_and_bounded():
block = al.ToolBlock("list_cookbook_servers", "{}")
answer = al._required_read_summary(block, {"output": "AI: 6 configured servers\n- A\n- B\n- C\n- D"}, 3)
assert "AI:" not in answer
assert "- C" in answer and "- D" not in answer
assert al._required_read_summary(block, {"output": ""})
def test_central_dispatcher_denial_never_owns_success_output(monkeypatch):
from src.tool_execution import execute_tool_block, NO_TOOL_SECURITY_CONTEXT
monkeypatch.setattr(al, "execute_tool_block", execute_tool_block)
operation = al._required_safe_read_operation(contract(notes_operation()))
_, result, answer = asyncio.run(al._dispatch_required_safe_read(
operation, disabled_tools={"manage_notes"}, security_context=NO_TOOL_SECURITY_CONTEXT))
assert result["exit_code"] != 0
assert answer == ""
def run_terminal_branch(monkeypatch, native_calls, result, operation=None, *, max_tool_calls=3):
"""Execute the actual async branch after parsing, without a provider call."""
source = Path(al.__file__)
tree = ast.parse(source.read_text())
branch = next(node for node in ast.walk(tree) if isinstance(node, ast.If)
and "_required_read" in {n.id for n in ast.walk(node.test) if isinstance(n, ast.Name)})
executor = AsyncMock(return_value=("manage_notes: list", result))
monkeypatch.setattr(al, "execute_tool_block", executor)
context = object()
policy = object()
operation = operation or notes_operation()
state = dict(
_required_read=al._required_safe_read_operation(contract(operation)),
guide_only=False, tool_events=[], max_tool_calls=max_tool_calls, native_tool_calls=native_calls,
_required_read_native_id=al._required_read_native_id,
_dispatch_required_safe_read=al._dispatch_required_safe_read,
_compute_final_metrics=lambda *a, **kw: {"tool_events": a[8]},
session_id="fixture", disabled_tools=set(), tool_policy=policy,
owner="fixture", workspace=None, run_security=context, active_document=None,
client_runtime_context=None, round_num=1, model="fixture-model",
actual_endpoint_id="fixture-endpoint", actual_endpoint_label="fixture",
requested_model="fixture-model", round_texts=[], round_models=[],
round_endpoint_ids=[], round_endpoint_labels=[], _last_route_request_messages=[],
_last_route_context_length=4096, real_input_tokens=20, real_output_tokens=5,
has_real_usage=True, time_to_first_token=0.1, _t0=time.time(),
json=json, time=time,
)
wrapper = ast.parse("async def run():\n yield 'unexpected synthesis'\n").body[0]
wrapper.body.insert(0, branch)
module = ast.fix_missing_locations(ast.Module(body=[wrapper], type_ignores=[]))
exec(compile(module, "<required-read-terminal-branch>", "exec"), state)
async def collect():
return [event async for event in state["run"]()]
events = asyncio.run(collect())
assert "unexpected synthesis" not in events
executor.assert_awaited_once()
assert json.loads(executor.call_args.args[0].content) == dict(operation.args)
assert executor.call_args.args[0].tool_type == operation.tool_name
assert executor.call_args.kwargs["security_context"] is context
assert executor.call_args.kwargs["tool_policy"] is policy
return events
@pytest.mark.parametrize("args", [None, "{broken", '{"action":"add","title":"Bad"}', '{"action":"list"}'])
def test_omitted_invalid_and_valid_model_args_use_immutable_read_and_finish(monkeypatch, args):
native = [] if args is None else [{"id": "call_native", "name": "manage_notes", "arguments": args}]
events = run_terminal_branch(monkeypatch, native, notes_result())
payloads = [json.loads(event[6:]) for event in events if event != "data: [DONE]\n\n"]
final = next(item for item in payloads if item.get("type") == "final_response")
assert final["content"].count("#note-") == 3
assert final["render_owner"] == "structured"
assert final["replacement_scope"] == "turn"
metrics = next(item["data"] for item in payloads if item.get("type") == "metrics")
assert metrics["required_operation_succeeded"] is True
assert metrics["render_owner"] == "structured"
assert len(metrics["tool_events"]) == 1
assert payloads[0]["call_id"] == ("call_native" if args == '{"action":"list"}' else "required-read-1")
assert events[-1] == "data: [DONE]\n\n"
def test_failed_read_reports_failure_without_retry_or_false_success(monkeypatch):
events = run_terminal_branch(monkeypatch, [], {"error": "permission denied", "exit_code": 1})
assert any("permission denied" in event for event in events)
assert any('"required_operation_succeeded": false' in event for event in events)
def test_zero_tool_budget_means_unlimited_for_required_read(monkeypatch):
events = run_terminal_branch(
monkeypatch, [], notes_result(), max_tool_calls=0,
)
assert any('"required_operation_succeeded": true' in event for event in events)
@pytest.mark.parametrize("tool", ["list_email_accounts", "mcp__email__list_email_accounts"])
@pytest.mark.parametrize("model_args", [None, "{broken", "{}"])
def test_email_metadata_read_replaces_model_call_and_terminates_once(monkeypatch, tool, model_args):
operation = Operation(tool, MappingProxyType({}))
native = [] if model_args is None else [{
"id": "email-call", "function": {"name": "list_email_accounts", "arguments": model_args},
}]
events = run_terminal_branch(monkeypatch, native, {
"output": "- Personal: fixture@example.test", "exit_code": 0,
}, operation)
payloads = [json.loads(event[6:]) for event in events[:-1]]
assert sum(p.get("type") == "tool_output" for p in payloads) == 1
final = next(p for p in payloads if p.get("type") == "final_response")
assert "fixture@example.test" in final["content"]
assert final["render_owner"] == "structured"
assert payloads[0]["call_id"] == ("email-call" if model_args == "{}" else "required-read-1")
assert events[-1] == "data: [DONE]\n\n"
@pytest.mark.parametrize("tool", ["list_email_accounts", "mcp__email__list_email_accounts"])
def test_email_metadata_requires_read_private_and_permission(monkeypatch, tool):
from src.turn_contract import RequiredReadOperation
sealed = SimpleNamespace(required_read_operation=RequiredReadOperation(tool),
capabilities={"email"}, permits=lambda name: name == tool)
assert al._required_safe_read_operation(sealed)[0].tool_type == tool
sealed.permits = lambda _: False
assert al._required_safe_read_operation(sealed) is None
sealed.permits = lambda _: True
monkeypatch.setattr(al, "capabilities_for_action", lambda *a: SimpleNamespace(
known=True, effects=frozenset()))
assert al._required_safe_read_operation(sealed) is None