Files
odysseus/tests/test_turn_contract_read_operations.py
T

304 lines
16 KiB
Python

"""Sealed read intent behavior; no inference, handlers, or external services."""
from copy import deepcopy
from dataclasses import FrozenInstanceError, replace
from types import SimpleNamespace
import pytest
from src.tool_policy import ToolPolicy
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.turn_contract import (
RequiredReadOperation, TurnContract, requested_capabilities,
required_read_operation_for_request, resolve_turn_contract,
)
def resolve(message, *, history=(), **kwargs):
return resolve_turn_contract(
capabilities=kwargs.pop("capabilities", requested_capabilities(message, history)),
schemas=kwargs.pop("schemas", FUNCTION_TOOL_SCHEMAS),
policy=kwargs.pop("policy", ToolPolicy()), message=message, history=history, **kwargs,
)
@pytest.mark.parametrize("message,tool,args", [
("List my notes", "manage_notes", {"action": "list"}),
("Show my calendar events", "manage_calendar", {"action": "list_events"}),
("List my calendars", "manage_calendar", {"action": "list_calendars"}),
("List my email accounts", "list_email_accounts", {}),
("List my configured email accounts", "list_email_accounts", {}),
("What's my email address?", "list_email_accounts", {}),
("List my scheduled tasks", "manage_tasks", {"action": "list"}),
("List my documents", "manage_documents", {"action": "list"}),
("Read my saved memories", "manage_memory", {"action": "list"}),
("Can you show my skills?", "manage_skills", {"action": "list"}),
("List my saved skills", "manage_skills", {"action": "list"}),
("List available models", "list_models", {}),
("List cached models", "list_cached_models", {}),
("List locally cached models", "list_cached_models", {}),
("List served models", "list_served_models", {}),
("List downloads", "list_downloads", {}),
("List serve presets", "list_serve_presets", {}),
("List cookbook servers", "list_cookbook_servers", {}),
("List my saved research reports", "manage_research", {"action": "list"}),
("List my chat sessions", "list_sessions", {}),
("List my contacts", "manage_contact", {"action": "list"}),
("Read note id abcd1234", "manage_notes", {"action": "view", "id": "abcd1234"}),
("Read document id doc-123", "manage_documents", {"action": "read", "document_id": "doc-123"}),
("View skill id release-check", "manage_skills", {"action": "view", "name": "release-check"}),
])
def test_explicit_reads_resolve_real_schema_operations(message, tool, args):
operation = required_read_operation_for_request(message)
assert operation == RequiredReadOperation(tool, args)
contract = resolve(message)
assert contract.required_read_operation == operation
assert tool in contract.required & contract.offered & contract.executable
assert not contract.unavailable
schema = next(s["function"] for s in contract.schemas() if s["function"]["name"] == tool)
properties = schema["parameters"]["properties"]
assert set(args) <= properties.keys()
assert set(schema["parameters"].get("required", [])) <= args.keys()
if "action" in args:
assert args["action"] in properties["action"]["enum"]
assert contract.audit()["required_read_operation"] == operation.audit()
def test_explicit_limit_is_presentation_bound_and_survives_exact_repeat():
message = "Show my first 3 notes"
operation = required_read_operation_for_request(message)
assert operation == RequiredReadOperation("manage_notes", {"action": "list"}, max_items=3)
assert "limit" not in operation.args
history = [{"role": "user", "content": message}]
assert resolve("Show them again", history=history).required_read_operation == operation
@pytest.mark.parametrize("followup", ["Do that again", "Repeat it", "Same list again", "Again!"])
def test_exact_repeat_resolves_nearest_user_read_and_ignores_assistant_suggestions(followup):
history = [SimpleNamespace(role="user", content="Read document id doc-7"),
{"role": "assistant", "content": "You could delete it or search the web."}]
operation = required_read_operation_for_request(followup, history)
assert operation == RequiredReadOperation("manage_documents", {"action": "read", "document_id": "doc-7"})
assert resolve(followup, history=history).required_read_operation == operation
@pytest.mark.parametrize("initial,followup,tool", [
("List available models", "Refresh that same model catalog list", "list_models"),
("List served models", "Refresh that same served-model list", "list_served_models"),
("List downloads", "Refresh that same downloads list", "list_downloads"),
("List serve presets", "Refresh that same serve-preset list", "list_serve_presets"),
("List locally cached models", "Refresh that same cached-model list", "list_cached_models"),
])
def test_refresh_named_list_repeats_exact_safe_read(initial, followup, tool):
history = [{"role": "user", "content": initial},
{"role": "assistant", "content": "The requested list."}]
expected = RequiredReadOperation(tool)
assert required_read_operation_for_request(followup, history) == expected
contract = resolve(followup, history=history)
assert contract.required_read_operation == expected
assert contract.required == {tool}
@pytest.mark.parametrize("initial,tool,args", [
("List my saved research reports. Return at most three titles. Read-only inspection; do not change data or send messages. Keep the answer concise.",
"manage_research", {"action": "list"}),
("List my chat sessions. Return at most three names. Read-only inspection; do not change data or send messages. Keep the answer concise.",
"list_sessions", {}),
("List my contacts. Return at most three names. Read-only inspection; do not change data or send messages. Keep the answer concise.",
"manage_contact", {"action": "list"}),
])
def test_supplemental_inventory_reads_and_referential_repeat_are_sealed(initial, tool, args):
operation = required_read_operation_for_request(initial)
assert operation == RequiredReadOperation(tool, args, max_items=3)
history = [{"role": "user", "content": initial}]
repeated = required_read_operation_for_request(
"List those again, at most three. Read-only; do not change data or send messages.",
history,
)
assert repeated == operation
assert resolve(initial).required == {tool}
assert resolve("List those again, at most three. Read-only; do not change data or send messages.",
history=history).required == {tool}
def test_repeat_chain_accepts_history_including_current_turn():
history = [{"role": "user", "content": "List my email accounts"},
{"role": "user", "content": "Repeat that"},
{"role": "user", "content": "Do that again"}]
assert required_read_operation_for_request("Do that again", iter(history)) == RequiredReadOperation("list_email_accounts")
@pytest.mark.parametrize("intervening", ["Delete my notes", "What is a prime number?", "Search the web for notes"])
def test_repeat_does_not_reach_past_a_new_user_intent(intervening):
history = [{"role": "user", "content": "List my notes"},
{"role": "user", "content": intervening}]
assert required_read_operation_for_request("Do that again", history) is None
@pytest.mark.parametrize("message", [
"Create a note", "Delete document id doc-1", "Run my tasks", "Send an email",
"List my email accounts and send an email", "List my notes and calendar",
"List my notes; delete the old ones", "Run ls", "List files in my workspace",
"Search my notes for lunch", "Summarize my documents", "Research calendar software",
"How do I list my notes?", "Can you explain how to read a document?",
"Read the document about lunch", "Read note id abc and delete it",
"List my events tomorrow", "List models on the remote server",
"Show those from yesterday", "Repeat it but only the first 2", "Again, delete it",
"List 0 notes", "List my notes except archived ones",
])
def test_non_exact_or_unsafe_requests_never_force_an_operation(message):
history = [{"role": "user", "content": "List my notes"}]
assert required_read_operation_for_request(message, history) is None
assert resolve(message, history=history).required_read_operation is None
def test_read_operation_copies_arguments_and_audit_is_detached():
args = {"action": "view", "id": "note-1"}
operation = RequiredReadOperation("manage_notes", args, max_items=1)
args["action"] = "delete"
assert operation.args["action"] == "view"
with pytest.raises(TypeError):
operation.args["id"] = "note-2"
with pytest.raises(FrozenInstanceError):
operation.tool = "bash"
with pytest.raises(FrozenInstanceError):
operation.max_items = 99
contract = resolve("Read note id note-1", required_read_operation=operation)
audit = contract.audit()
audit["required_read_operation"]["args"]["action"] = "delete"
assert contract.required_read_operation.args == {"action": "view", "id": "note-1"}
with pytest.raises(FrozenInstanceError):
contract.required_read_operation = None
@pytest.mark.parametrize("tool,args", [
("bash", {}), ("send_email", {}), ("web_search", {}),
("manage_notes", {"action": "delete", "id": "note-1"}),
("manage_notes", {"action": "search", "query": "lunch"}),
("manage_tasks", {"action": "run"}),
("manage_memory", {"action": "list", "command": "delete\nall"}),
("manage_notes", {"action": "list", "content": "replacement"}),
("manage_notes", {"action": "list", "archived": []}),
("manage_documents", {"action": "read"}),
("manage_skills", {"action": "view", "name": ""}),
("list_email_accounts", {"_odysseus_owner": "someone-else"}),
("list_email_accounts", {"action": None}),
])
def test_type_rejects_mutations_and_unsafe_argument_shapes(tool, args):
with pytest.raises(ValueError):
RequiredReadOperation(tool, args)
@pytest.mark.parametrize("value", [0, -1, True, "5", 1.5])
def test_max_items_requires_positive_integer(value):
with pytest.raises(ValueError):
RequiredReadOperation("list_models", max_items=value)
@pytest.mark.parametrize("mode", ["missing", "disabled", "hidden", "all", "unselected", "wrong_family"])
def test_required_read_cannot_run_unavailable_or_outside_scope(mode):
kwargs = {}
if mode == "missing":
kwargs["schemas"] = [s for s in FUNCTION_TOOL_SCHEMAS if s["function"]["name"] != "list_models"]
elif mode in {"disabled", "hidden"}:
kwargs["policy"] = ToolPolicy(**{f"{mode}_tools": frozenset({"list_models"})})
elif mode == "all":
kwargs["policy"] = ToolPolicy(block_all_tool_calls=True)
elif mode == "unselected":
kwargs["selected_tools"] = {"list_downloads"}
else:
kwargs["capabilities"] = {"calendar"}
contract = resolve("List available models", **kwargs)
assert contract.required_read_operation == RequiredReadOperation("list_models")
assert "list_models" in contract.unavailable
assert contract.offered == contract.required == set()
assert contract.schemas() == []
def test_other_missing_requirement_also_marks_sealed_read_unavailable():
contract = resolve("List my notes", required_tools={"send_email"})
assert {"send_email", "manage_notes"} <= contract.unavailable
assert contract.offered == set()
assert contract.required_read_operation.tool == "manage_notes"
@pytest.mark.parametrize("denied", [False, True])
def test_required_account_read_resolves_to_real_mcp_schema_or_reports_denial(denied):
schema = deepcopy(next(s for s in FUNCTION_TOOL_SCHEMAS if s["function"]["name"] == "list_email_accounts"))
schema["function"]["name"] = "mcp__email__list_email_accounts"
contract = resolve("List my email accounts", schemas=[*FUNCTION_TOOL_SCHEMAS, schema],
selected_tools={"list_email_accounts"},
policy=ToolPolicy(disabled_tools=frozenset({"list_email_accounts"}) if denied else frozenset()))
if denied:
assert "list_email_accounts" in contract.unavailable
assert not contract.offered
else:
assert contract.required_read_operation.tool == "mcp__email__list_email_accounts"
assert contract.required == {"mcp__email__list_email_accounts"}
def test_existing_constructor_and_resolver_api_remain_compatible():
empty = TurnContract(frozenset(), frozenset(), frozenset(), frozenset(), frozenset(), ())
assert empty.required_read_operation is None
legacy = resolve_turn_contract(capabilities={"cookbook_admin"}, schemas=FUNCTION_TOOL_SCHEMAS, policy=ToolPolicy())
assert legacy.required_read_operation is None
assert "required_read_operation" not in legacy.audit()
assert "list_models" not in legacy.required
with pytest.raises(ValueError, match="available"):
replace(empty, required_read_operation=RequiredReadOperation("list_models"))
def test_exact_live_notes_prompt_and_repeat_keep_safe_operation_and_limit():
message = (
"List my notes. Return at most three titles. Read-only inspection; "
"do not change data or send messages. Keep the answer concise."
)
expected = RequiredReadOperation("manage_notes", {"action": "list"}, max_items=3)
assert required_read_operation_for_request(message) == expected
contract = resolve(message)
assert contract.required_read_operation == expected
assert contract.required == {"manage_notes"}
followup = "List those again, at most three. Read-only; do not change data or send messages."
history = [{"role": "user", "content": message},
{"role": "assistant", "content": "Three note titles."}]
assert required_read_operation_for_request(followup, history) == expected
assert resolve(followup, history=history).required_read_operation == expected
@pytest.mark.parametrize("message", ["List my email accounts.", "Show my email accounts."])
def test_exact_live_metadata_mode_email_prompt(message):
assert resolve(message).required_read_operation == RequiredReadOperation("list_email_accounts")
@pytest.mark.parametrize("message", [
"List my notes and delete the old ones. Read-only; do not change data or send messages.",
"List my notes. Return at most three titles and send them to me.",
"List my notes. Send an email. Keep the answer concise.",
"List my notes. Read-only; do not change data or send messages except to Bob.",
"List those again, at most three, then delete them. Read-only.",
"Summarize my notes. Return at most three titles. Read-only inspection.",
"List my notes. Return at most zero titles.",
])
def test_presentation_suffixes_never_hide_real_compound_actions(message):
assert required_read_operation_for_request(message, [{"role": "user", "content": "List my notes"}]) is None
def test_safe_suffix_limit_is_not_lost_when_repeat_inherits_plain_request():
history = [{"role": "user", "content": "List my notes"}]
operation = required_read_operation_for_request("List those again, at most three. Read-only.", history)
assert operation == RequiredReadOperation("manage_notes", {"action": "list"}, 3)
assert required_read_operation_for_request("List my notes. Return at most 5 titles. Return at most three titles.").max_items == 3
@pytest.mark.parametrize("message,tool,args,limit", [
("List my scheduled tasks. Return at most three names and statuses. Read-only inspection; do not change data or send messages. Keep the answer concise.",
"manage_tasks", {"action": "list"}, 3),
("List my saved memories. Return at most three short entries. Read-only inspection; do not change data or send messages. Keep the answer concise.",
"manage_memory", {"action": "list"}, 3),
("List configured Cookbook servers. Return only names and status. Read-only inspection; do not change data or send messages. Keep the answer concise.",
"list_cookbook_servers", {}, None),
])
def test_exact_live_output_wording_remains_a_safe_read(message, tool, args, limit):
assert required_read_operation_for_request(message) == RequiredReadOperation(
tool, args, max_items=limit,
)