The Allow / Always allow / Deny controls only lived in the fallback tool
card, but the built-in tools (web search, python, terminal, code
execution, image generation) render with their own components and so
never showed the buttons. Those calls paused after tool_start with no way
to approve them, hanging until the 1 hour timeout. Only MCP tools, which
use the fallback renderer, actually worked.
Render the controls for every tool card by wrapping each registered tool
component (and the fallback) in thread.tsx with a shared
ToolConfirmationControls, so the gate applies uniformly.
Also make the handshake robust:
- The gate keys on a per-call approval_id minted by the backend and
echoed in tool_start, instead of session_id alone, so a stale or
concurrent confirmation can no longer resolve the wrong call.
- The approval slot is registered before tool_start is yielded, closing
the race where a fast click or an auto "Always allow" could reach the
backend before the waiter existed.
- The frontend resolves with the same session id the request was sent
with (plus the approval_id), fixing the new-thread mismatch where the
confirmation targeted a different session than the blocked stream.
- The confirm endpoint returns {resolved}; the UI keeps the buttons and
shows a retry hint until the backend confirms a match, instead of
hiding them on a failed or mistargeted post.
- The gate runs after the disabled-tool and duplicate-call checks, so a
call that will not execute is not put up for approval. A denied call is
still excluded from duplicate detection, so re-issuing and approving it
works.
- "Always allow" is scoped per session to match the backend gate.
Add backend tests for the approval registry, the SSE no-deadlock
handshake, and the loop integration (allow, deny, disabled, duplicate,
re-issue after deny).
165 lines
5.8 KiB
Python
165 lines
5.8 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Integration tests for the confirmation gate inside the real tool loop.
|
|
|
|
These drive ``run_safetensors_tool_loop`` (no model -- hand-crafted fake
|
|
generators) with ``confirm_tool_calls=True`` and resolve each pending
|
|
decision inline. The slot is registered before ``tool_start`` is yielded,
|
|
so resolving right after receiving that event always lands before the
|
|
loop blocks. Covers: allow executes once, deny skips execution and feeds
|
|
back the rejection, disabled/duplicate calls are not prompted, and a
|
|
denied call does not pollute duplicate detection.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from core.inference import safetensors_agentic
|
|
from core.inference.safetensors_agentic import run_safetensors_tool_loop
|
|
from core.inference.tool_call_parser import DUPLICATE_CALL_NUDGE
|
|
from state import tool_approvals
|
|
from state.tool_approvals import TOOL_REJECTED_MESSAGE, resolve_tool_decision
|
|
|
|
_SESSION = "loop-session"
|
|
|
|
|
|
@pytest.fixture(autouse = True)
|
|
def _clear_pending():
|
|
with tool_approvals._lock:
|
|
tool_approvals._pending.clear()
|
|
yield
|
|
with tool_approvals._lock:
|
|
tool_approvals._pending.clear()
|
|
|
|
|
|
class _FakeExecuteTool:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def __call__(self, name, arguments, *, cancel_event = None, timeout = None, session_id = None):
|
|
self.calls.append((name, arguments))
|
|
return f"RESULT[{name}]"
|
|
|
|
|
|
def _tool_call(name, args_json):
|
|
return f'<tool_call>{{"name": "{name}", "arguments": {args_json}}}</tool_call>'
|
|
|
|
|
|
def _multi_turn(turns):
|
|
"""A single_turn generator that yields one full snapshot per turn."""
|
|
turn_iter = iter(turns)
|
|
|
|
def _gen(_messages):
|
|
try:
|
|
yield next(turn_iter)
|
|
except StopIteration:
|
|
return
|
|
|
|
return _gen
|
|
|
|
|
|
_DEFAULT_TOOLS = [
|
|
{"type": "function", "function": {"name": "python"}},
|
|
{"type": "function", "function": {"name": "web_search"}},
|
|
]
|
|
|
|
|
|
def _drive(turns, decisions, *, tools = None):
|
|
"""Run the loop, resolving each gated tool_start with the next decision.
|
|
|
|
The advertised ``tools`` list drives the loop's enabled-tool filter
|
|
(pass a list omitting a tool to make a call to it "disabled").
|
|
Returns (events, execute_calls).
|
|
"""
|
|
decision_iter = iter(decisions)
|
|
exec_fn = _FakeExecuteTool()
|
|
gen = run_safetensors_tool_loop(
|
|
single_turn = _multi_turn(turns),
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
tools = _DEFAULT_TOOLS if tools is None else tools,
|
|
execute_tool = exec_fn,
|
|
session_id = _SESSION,
|
|
confirm_tool_calls = True,
|
|
)
|
|
events = []
|
|
for ev in gen:
|
|
events.append(ev)
|
|
if ev["type"] == "tool_start" and ev.get("awaiting_confirmation"):
|
|
# Slot is already registered (begin ran before this yield), so
|
|
# the decision lands before the loop enters its blocking wait.
|
|
resolve_tool_decision(
|
|
ev["approval_id"], next(decision_iter), session_id = _SESSION
|
|
)
|
|
return events, exec_fn.calls
|
|
|
|
|
|
def _tool_starts(events):
|
|
return [e for e in events if e["type"] == "tool_start"]
|
|
|
|
|
|
def _tool_ends(events):
|
|
return [e for e in events if e["type"] == "tool_end"]
|
|
|
|
|
|
def test_allow_executes_the_tool_once():
|
|
events, calls = _drive(
|
|
[_tool_call("python", '{"code": "print(1)"}'), "final answer"],
|
|
["allow"],
|
|
)
|
|
starts = _tool_starts(events)
|
|
assert len(starts) == 1
|
|
assert starts[0]["awaiting_confirmation"] is True
|
|
assert starts[0]["approval_id"]
|
|
assert calls == [("python", {"code": "print(1)"})]
|
|
assert _tool_ends(events)[0]["result"] == "RESULT[python]"
|
|
|
|
|
|
def test_deny_skips_execution_and_feeds_rejection():
|
|
events, calls = _drive(
|
|
[_tool_call("python", '{"code": "print(1)"}'), "final answer"],
|
|
["deny"],
|
|
)
|
|
assert calls == [] # tool never ran
|
|
assert _tool_ends(events)[0]["result"] == TOOL_REJECTED_MESSAGE
|
|
|
|
|
|
def test_disabled_tool_is_not_prompted():
|
|
# python is not advertised -> short-circuited, no approval asked.
|
|
events, calls = _drive(
|
|
[_tool_call("python", '{"code": "print(1)"}'), "final answer"],
|
|
[], # no decisions consumed
|
|
tools = [{"type": "function", "function": {"name": "web_search"}}],
|
|
)
|
|
starts = _tool_starts(events)
|
|
assert starts[0]["awaiting_confirmation"] is False
|
|
assert starts[0]["approval_id"] == ""
|
|
assert calls == []
|
|
assert "not enabled" in _tool_ends(events)[0]["result"]
|
|
|
|
|
|
def test_duplicate_call_is_not_prompted():
|
|
same = _tool_call("python", '{"code": "print(1)"}')
|
|
events, calls = _drive([same, same, "final answer"], ["allow"])
|
|
starts = _tool_starts(events)
|
|
assert len(starts) == 2
|
|
# First call gated + executed; second is a duplicate -> no prompt.
|
|
assert starts[0]["awaiting_confirmation"] is True
|
|
assert starts[1]["awaiting_confirmation"] is False
|
|
assert calls == [("python", {"code": "print(1)"})]
|
|
assert _tool_ends(events)[1]["result"] == DUPLICATE_CALL_NUDGE
|
|
|
|
|
|
def test_denied_call_can_be_reissued_and_approved():
|
|
# Deny, then the model re-issues the identical call -> approving it must
|
|
# execute, not get suppressed as a duplicate (denied calls are not added
|
|
# to the duplicate-detection history).
|
|
same = _tool_call("python", '{"code": "print(1)"}')
|
|
events, calls = _drive([same, same, "final answer"], ["deny", "allow"])
|
|
starts = _tool_starts(events)
|
|
assert len(starts) == 2
|
|
assert starts[0]["awaiting_confirmation"] is True
|
|
assert starts[1]["awaiting_confirmation"] is True # not treated as dup
|
|
assert calls == [("python", {"code": "print(1)"})] # ran once, on approve
|
|
ends = _tool_ends(events)
|
|
assert ends[0]["result"] == TOOL_REJECTED_MESSAGE
|
|
assert ends[1]["result"] == "RESULT[python]"
|