Files
NCBM 6d7c239718 test: cover the approval line and the policy records
Registry cases (none / once / round / grant / YOLO / denial / error / handler
record / closest answer wins), the wire cap keeping the line, the three policy
paths that record their answer, and a pretty detail that ignores the header.
The existing confirm tests now assert the line they gain.
2026-10-29 18:29:28 +08:00

155 lines
5.3 KiB
Python

"""The ``[approval]`` line: a confirmed call says how it got through."""
from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import (
Approval,
BatchConfirmDecision,
ToolRegistry,
ToolTag,
format_approval,
most_specific_approval,
tool,
)
from plyngent.agent.budget import cap_tool_message
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
from plyngent.tools.context import SessionState, bind_tool_context, record_approval
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from plyngent.agent import ToolConfirmRequest
def _danger(name: str, _args: Mapping[str, object]) -> str | None:
return f"mutate {name}"
def _allow(_name: str, _args: Mapping[str, object], _reason: str) -> bool:
return True
@tool(register=False)
async def quiet() -> str:
return "quiet"
@tool(register=False)
async def mutate() -> str:
return "ran"
async def test_a_call_that_needs_no_confirmation_carries_no_line() -> None:
"""The common case stays byte-for-byte what the tool returned."""
def danger(_name: str, _args: Mapping[str, object]) -> str | None:
return None
def on_confirm(_name: str, _args: Mapping[str, object], _reason: str) -> bool:
msg = "no prompt expected"
raise AssertionError(msg)
reg = ToolRegistry([quiet], danger=danger, on_confirm=on_confirm)
assert await reg.execute("quiet", "{}") == "quiet"
async def test_a_single_prompt_leads_the_result() -> None:
reg = ToolRegistry([mutate], danger=_danger, on_confirm=_allow)
assert await reg.execute("mutate", "{}") == "[approval] user (once)\nran"
async def test_yolo_mode_says_it_dropped_the_prompt() -> None:
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def yolo_ok() -> str:
return "y"
reg = ToolRegistry([yolo_ok], danger=_danger, on_confirm=_allow, yolo=True)
assert await reg.execute("yolo_ok", "{}") == "[approval] yolo (auto)\ny"
async def test_a_batch_answer_covers_its_round() -> None:
def on_batch(_requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
return BatchConfirmDecision(allowed=True)
reg = ToolRegistry([mutate], danger=_danger, on_confirm_batch=on_batch)
calls = [("mutate", "{}"), ("mutate", "{}")]
decisions = await reg.prepare_batch_confirm(calls)
results = [
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
]
assert results == ["[approval] user (round)\nran", "[approval] user (round)\nran"]
async def test_a_session_grant_says_nobody_was_asked_this_time() -> None:
@tool(tags=ToolTag.LOCAL | ToolTag.TRUSTABLE, register=False)
async def trust_me() -> str:
return "ok"
prompts: list[str] = []
def on_confirm(name: str, _args: Mapping[str, object], _reason: str) -> bool:
prompts.append(name)
return True
session = SessionState(session_id=1)
reg = ToolRegistry([trust_me], danger=_danger, on_confirm=on_confirm, session_state=session)
with bind_tool_context(session=session):
assert await reg.execute("trust_me", "{}") == "[approval] user (once)\nok"
assert await reg.execute("trust_me", "{}") == "[approval] grant (session)\nok"
assert prompts == ["trust_me"]
async def test_a_denied_call_carries_no_line() -> None:
reg = ToolRegistry([mutate], danger=_danger, on_confirm=lambda *_args: False)
out = await reg.execute("mutate", "{}")
assert out.startswith("error: ")
assert "[approval]" not in out
async def test_a_failed_call_carries_no_line() -> None:
"""An approved call that failed is an error first; the answer explains nothing."""
@tool(register=False)
async def boom() -> str:
return "error: boom"
reg = ToolRegistry([boom], danger=_danger, on_confirm=_allow)
assert await reg.execute("boom", "{}") == "error: boom"
async def test_a_handler_record_leads_the_result() -> None:
@tool(register=False)
async def policy_tool() -> str:
record_approval(Approval("user", "process"))
return "ran"
reg = ToolRegistry([policy_tool])
assert await reg.execute("policy_tool", "{}") == "[approval] user (process)\nran"
async def test_the_closest_answer_wins() -> None:
"""YOLO let the call run, but a human answered a policy prompt inside it."""
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def yolo_policy() -> str:
record_approval(Approval("user", "process"))
return "ran"
reg = ToolRegistry([yolo_policy], danger=_danger, on_confirm=_allow, yolo=True)
assert await reg.execute("yolo_policy", "{}") == "[approval] user (process)\nran"
async def test_the_line_survives_the_wire_cap() -> None:
approved = f"{format_approval(Approval('user', 'once'))}\n{'x' * 500}"
capped = cap_tool_message(ToolChatMessage(content=approved, tool_call_id="call-1"), 200)
assert capped.content.startswith("[approval] user (once)\n")
assert "document_id=call-1" in capped.content
def test_the_most_specific_record_wins() -> None:
records = [Approval("yolo", "auto"), Approval("grant", "session"), Approval("user", "once")]
assert most_specific_approval(records) == Approval("user", "once")
assert most_specific_approval([]) is None