diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py
index b6f83b760c..ac9d370b7d 100644
--- a/studio/backend/core/inference/llama_cpp.py
+++ b/studio/backend/core/inference/llama_cpp.py
@@ -226,6 +226,10 @@ _MAX_REPROMPTS = 1
# enough for reasoning-heavy GGUFs and max_tokens-omitting API clients.
_DEFAULT_MAX_TOKENS_FLOOR = 32768
_DEFAULT_FIRST_TOKEN_TIMEOUT_S = 1200.0 # 20 min
+
+# Only large streamed tool payloads get an early provisional card; render_html
+# is exempt because it needs immediate artifact feedback.
+_PROVISIONAL_ARGS_MIN_CHARS = 256
_DEFAULT_STREAM_STALL_TIMEOUT_S = 120.0 # 2 min
_REPROMPT_MAX_CHARS = 2000
_FORCED_REPEAT_PLAN_SIGNAL = re.compile(
@@ -7900,7 +7904,9 @@ class LlamaCppBackend:
_iter_finish_reason = None
_stream_done = False
_last_emitted = ""
- provisional_render_html_tool_call_ids = set()
+ # Provisional tool_start cards already shown, keyed by tool_call_id.
+ provisional_started_tool_calls: dict[str, str] = {}
+ resolved_provisional_tool_call_ids: set[str] = set()
_suppress_visible_output = _forced_tool_call_pending
with self._open_stream(url, payload, cancel_event) as (
@@ -7966,11 +7972,8 @@ class LlamaCppBackend:
# ── Structured tool_calls ──
tc_deltas = delta.get("tool_calls")
if tc_deltas:
- # llama-server can emit visible assistant
- # preface content before native structured
- # tool_calls. Preserve content_accum as
- # the assistant pre-tool text and still
- # drain/execute the structured call.
+ # Preserve any visible preface before draining
+ # the structured tool call.
has_structured_tc = True
detect_state = _S_DRAINING
for tc_d in tc_deltas:
@@ -8001,27 +8004,54 @@ class LlamaCppBackend:
fallback_id = f"call_{idx}"
current_id = tool_calls_acc[idx].get("id", fallback_id)
already_started = (
- current_id in provisional_render_html_tool_call_ids
+ current_id in provisional_started_tool_calls
)
- has_real_id = current_id != fallback_id
- if (
+ # Empty/synthetic ids cannot reconcile with real starts.
+ has_real_id = bool(current_id) and current_id != fallback_id
+ # Show one early card per eligible streamed tool call.
+ _is_completed_one_shot = (
current_name == "render_html"
- and not _tool_succeeded("render_html")
+ and _tool_succeeded("render_html")
+ )
+ # render_html is one-shot.
+ _one_shot_already_provisional = (
+ current_name == "render_html"
+ and "render_html"
+ in provisional_started_tool_calls.values()
+ )
+ # Later parallel cards only reconcile when parallel use is enabled.
+ _confirm_gated = (
+ confirm_tool_calls and not bypass_permissions
+ )
+ # Keep small-argument tools on the normal path.
+ _args_len = len(
+ tool_calls_acc[idx]["function"].get("arguments", "")
+ )
+ _payload_is_large = (
+ current_name == "render_html"
+ or _args_len >= _PROVISIONAL_ARGS_MIN_CHARS
+ )
+ if (
+ current_name
+ and (idx == 0 or not disable_parallel_tool_use)
+ and has_real_id
+ and not already_started
+ and not _is_completed_one_shot
+ and not _one_shot_already_provisional
+ and not _confirm_gated
+ and _payload_is_large
and any(
- (
- (tool.get("function") or {}).get("name")
- == "render_html"
- )
+ (tool.get("function") or {}).get("name")
+ == current_name
for tool in active_tools
)
- and not already_started
- and not provisional_render_html_tool_call_ids
- and has_real_id
):
- provisional_render_html_tool_call_ids.add(current_id)
+ provisional_started_tool_calls[current_id] = (
+ current_name
+ )
yield {
"type": "tool_start",
- "tool_name": "render_html",
+ "tool_name": current_name,
"tool_call_id": current_id,
"arguments": {},
"provenance": tool_event_provenance(
@@ -8343,20 +8373,30 @@ class LlamaCppBackend:
for tc in tool_calls or []:
func = tc.get("function", {})
tool_name = func.get("name", "")
- provisional_render_html_match = (
- tool_name == "render_html"
- and tc.get("id") in provisional_render_html_tool_call_ids
- )
+ provisional_match = tc.get("id") in provisional_started_tool_calls
decision = tool_controller.prepare_call(
tc,
forced = _forced_tool_call_pending,
- provisional = provisional_render_html_match,
+ provisional = provisional_match,
)
if not decision.should_execute:
if content_text and not assistant_appended:
conversation.append(assistant_msg)
assistant_appended = True
+ if provisional_match:
+ # A provisional tool card is already on screen for this
+ # id; close it so it never dangles when the controller
+ # turns the call into an internal no-op (duplicate /
+ # disabled / render_html_repeat).
+ resolved_provisional_tool_call_ids.add(decision.tool_call_id)
+ yield {
+ "type": "tool_end",
+ "tool_name": decision.tool_name,
+ "tool_call_id": decision.tool_call_id,
+ "result": "",
+ "provenance": decision.provenance,
+ }
completion = tool_controller.record_noop(decision)
conversation.append(completion.model_message())
if _forced_tool_call_pending:
@@ -8401,6 +8441,7 @@ class LlamaCppBackend:
== "deny"
):
decision_slot = None
+ resolved_provisional_tool_call_ids.add(decision.tool_call_id)
yield {
"type": "tool_end",
"tool_name": decision.tool_name,
@@ -8444,12 +8485,25 @@ class LlamaCppBackend:
if decision.tool_name == "search_knowledge_base":
_kb_search_count += 1
completion = tool_controller.record_result(decision, result)
+ resolved_provisional_tool_call_ids.add(decision.tool_call_id)
yield completion.tool_end_event()
conversation.append(completion.tool_message())
if _forced_tool_call_pending:
_forced_tool_call_pending = False
+ # Close provisional cards not resolved by execution/no-op handling.
+ for _pid, _pname in provisional_started_tool_calls.items():
+ if _pid not in resolved_provisional_tool_call_ids:
+ resolved_provisional_tool_call_ids.add(_pid)
+ yield {
+ "type": "tool_end",
+ "tool_name": _pname,
+ "tool_call_id": _pid,
+ "result": "",
+ "provenance": tool_event_provenance(provisional = True),
+ }
+
# Clear tool status badge before next generation/final pass.
yield {"type": "status", "text": ""}
if tool_controller.force_final_answer or not tool_controller.active_tools():
@@ -8458,10 +8512,32 @@ class LlamaCppBackend:
continue
except httpx.ConnectError:
+ # Mark unresolved provisional cards as failed before raising.
+ for _pid, _pname in provisional_started_tool_calls.items():
+ if _pid not in resolved_provisional_tool_call_ids:
+ resolved_provisional_tool_call_ids.add(_pid)
+ yield {
+ "type": "tool_end",
+ "tool_name": _pname,
+ "tool_call_id": _pid,
+ "result": "Error: lost connection to llama-server before the tool call completed.",
+ "provenance": tool_event_provenance(provisional = True),
+ }
raise RuntimeError("Lost connection to llama-server")
except Exception as e:
if cancel_event is not None and cancel_event.is_set():
return
+ # Same cleanup for other mid-iteration failures.
+ for _pid, _pname in provisional_started_tool_calls.items():
+ if _pid not in resolved_provisional_tool_call_ids:
+ resolved_provisional_tool_call_ids.add(_pid)
+ yield {
+ "type": "tool_end",
+ "tool_name": _pname,
+ "tool_call_id": _pid,
+ "result": "Error: the tool call was interrupted before it completed.",
+ "provenance": tool_event_provenance(provisional = True),
+ }
raise
# ── Tool iteration cap reached -- synthesize final answer ──
diff --git a/studio/backend/core/inference/safetensors_agentic.py b/studio/backend/core/inference/safetensors_agentic.py
index 06b6cbe57f..0c96378d6c 100644
--- a/studio/backend/core/inference/safetensors_agentic.py
+++ b/studio/backend/core/inference/safetensors_agentic.py
@@ -236,12 +236,39 @@ def run_safetensors_tool_loop(
cumulative_display = ""
last_emitted = ""
provisional_render_html_started = False
+ provisional_resolved = False
provisional_render_html_id = f"call_{next_call_id}"
+ # When a human confirmation gate is active the real tool_start is keyed
+ # by an approval id and carries awaiting_confirmation, so an early
+ # provisional card (keyed by tool_call_id, no approval) would show the
+ # tool as "running" before the user has approved it. Suppress the early
+ # card in that case and let the gated tool_start be the first signal.
+ _provisional_confirm_gated = bool(confirm_tool_calls) and not bypass_permissions
gen = _call_single_turn(single_turn, conversation, active_tools)
prev_cumulative = ""
- for cumulative in gen:
+ _gen_iter = iter(gen)
+ while True:
+ try:
+ cumulative = next(_gen_iter)
+ except StopIteration:
+ break
+ except Exception:
+ # The model pipeline raised mid-stream. If a provisional
+ # render_html card was already surfaced, close it as errored so
+ # the UI never leaves a tool card spinning after the turn fails.
+ if provisional_render_html_started and not provisional_resolved:
+ provisional_resolved = True
+ yield {
+ "type": "tool_end",
+ "tool_name": "render_html",
+ "tool_call_id": provisional_render_html_id,
+ "result": "Error: generation was interrupted before the tool call completed.",
+ "provenance": _tool_event_provenance(provisional = True),
+ }
+ raise
+
if cancel_event is not None and cancel_event.is_set():
return
@@ -257,6 +284,7 @@ def run_safetensors_tool_loop(
if detect_state == _state_draining:
if (
not _tool_succeeded("render_html")
+ and not _provisional_confirm_gated
and any(
((tool.get("function") or {}).get("name") == "render_html")
for tool in active_tools
@@ -295,6 +323,7 @@ def run_safetensors_tool_loop(
detect_state = _state_draining
if (
not _tool_succeeded("render_html")
+ and not _provisional_confirm_gated
and any(
((tool.get("function") or {}).get("name") == "render_html")
for tool in active_tools
@@ -353,6 +382,7 @@ def run_safetensors_tool_loop(
detect_state = _state_draining
if (
not _tool_succeeded("render_html")
+ and not _provisional_confirm_gated
and any(
((tool.get("function") or {}).get("name") == "render_html")
for tool in active_tools
@@ -460,7 +490,8 @@ def run_safetensors_tool_loop(
tool_protocol_active = False,
),
}
- if provisional_render_html_started:
+ if provisional_render_html_started and not provisional_resolved:
+ provisional_resolved = True
yield {
"type": "tool_end",
"tool_name": "render_html",
@@ -503,6 +534,18 @@ def run_safetensors_tool_loop(
if content_text and not assistant_appended:
conversation.append(assistant_msg)
assistant_appended = True
+ if provisional_match and not provisional_resolved:
+ # A provisional render_html card is already on screen for
+ # this id; close it so it never dangles when the controller
+ # turns the call into an internal no-op (duplicate / repeat).
+ provisional_resolved = True
+ yield {
+ "type": "tool_end",
+ "tool_name": decision.tool_name,
+ "tool_call_id": decision.tool_call_id,
+ "result": "",
+ "provenance": decision.provenance,
+ }
completion = tool_controller.record_noop(decision)
conversation.append(completion.model_message())
logger.info(
@@ -541,6 +584,8 @@ def run_safetensors_tool_loop(
== "deny"
):
decision_slot = None
+ if provisional_match:
+ provisional_resolved = True
yield {
"type": "tool_end",
"tool_name": decision.tool_name,
@@ -587,6 +632,8 @@ def run_safetensors_tool_loop(
kb_search_count += 1
completion = tool_controller.record_result(decision, result)
+ if provisional_match:
+ provisional_resolved = True
yield completion.tool_end_event()
conversation.append(completion.tool_message())
diff --git a/studio/backend/tests/test_llama_cpp_tool_loop.py b/studio/backend/tests/test_llama_cpp_tool_loop.py
index 7b1ecbca61..687829980b 100644
--- a/studio/backend/tests/test_llama_cpp_tool_loop.py
+++ b/studio/backend/tests/test_llama_cpp_tool_loop.py
@@ -20,7 +20,7 @@ _BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
-from core.inference.llama_cpp import LlamaCppBackend
+from core.inference.llama_cpp import _PROVISIONAL_ARGS_MIN_CHARS, LlamaCppBackend
from state import tool_approvals
from state.tool_approvals import TOOL_REJECTED_MESSAGE, resolve_tool_decision
@@ -1325,3 +1325,323 @@ def test_confirm_tool_calls_deny_skips_gguf_tool_and_retry_can_execute(monkeypat
assert len(starts) == 2
assert [event["result"] for event in ends] == [TOOL_REJECTED_MESSAGE, "OK"]
assert calls == [("python", {"code": "print(1)"})]
+
+
+def _streamed_structured_tool_call(
+ tool_name: str,
+ arguments: dict,
+ call_id: str,
+ frag: int = 24,
+) -> list[str]:
+ """A structured tool call whose arguments arrive token-by-token across many
+ deltas (id + name on the first delta), mirroring how llama-server streams a
+ large tool-call argument such as a full HTML/code file."""
+ args_json = json.dumps(arguments)
+ fragments = [args_json[i : i + frag] for i in range(0, len(args_json), frag)] or [""]
+ chunks = [
+ _sse(
+ {
+ "tool_calls": [
+ {
+ "index": 0,
+ "id": call_id,
+ "type": "function",
+ "function": {"name": tool_name, "arguments": fragments[0]},
+ }
+ ]
+ }
+ )
+ ]
+ for fragment in fragments[1:]:
+ chunks.append(_sse({"tool_calls": [{"index": 0, "function": {"arguments": fragment}}]}))
+ chunks.append(_done())
+ return chunks
+
+
+def test_large_python_tool_call_emits_early_provisional_start(monkeypatch):
+ """Regression: a large streamed tool-call argument surfaces a provisional
+ tool card BEFORE the full arguments finish, so the UI shows progress during
+ generation instead of a frozen 'Generating...'. (The bug: only render_html
+ surfaced early; python/terminal/etc. were silent until the call completed.)"""
+
+ big_code = "total = 0\n" + "\n".join(f"total += {i}" for i in range(120))
+ args_json = json.dumps({"code": big_code})
+ assert len(args_json) > _PROVISIONAL_ARGS_MIN_CHARS
+
+ first_stream = _streamed_structured_tool_call("python", {"code": big_code}, "call_py_big")
+ final_stream = [_sse({"content": "Done."}), _done()]
+ payloads: list[dict] = []
+ backend = _make_backend(monkeypatch, [first_stream, final_stream], payloads)
+
+ calls: list[tuple[str, dict]] = []
+
+ def fake_execute_tool(name, arguments, **_kwargs):
+ calls.append((name, arguments))
+ return "OK"
+
+ monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
+
+ events = list(
+ backend.generate_chat_completion_with_tools(
+ messages = [{"role": "user", "content": "write code"}],
+ tools = [{"type": "function", "function": {"name": "python"}}],
+ max_tool_iterations = 1,
+ )
+ )
+
+ tool_starts = [e for e in events if e.get("type") == "tool_start"]
+ provisional = [e for e in tool_starts if not e.get("arguments")]
+ real = [e for e in tool_starts if e.get("arguments", {}).get("code")]
+
+ # Exactly one provisional (empty args) and one real (full args), same id so
+ # the frontend reconciles them into a single card.
+ assert len(provisional) == 1, tool_starts
+ assert provisional[0]["tool_name"] == "python"
+ assert provisional[0]["tool_call_id"] == "call_py_big"
+ assert provisional[0]["provenance"].get("provisional") is True
+ assert len(real) == 1
+ assert real[0]["tool_call_id"] == "call_py_big"
+ # The provisional card appears before the real (completed) tool_start.
+ assert events.index(provisional[0]) < events.index(real[0])
+
+ assert calls == [("python", {"code": big_code})]
+ assert any(e.get("type") == "tool_end" and e.get("tool_name") == "python" for e in events)
+
+
+def test_small_python_tool_call_has_no_provisional_start(monkeypatch):
+ """A small tool-call argument finishes streaming instantly, so it keeps the
+ existing behavior of a single (real) tool_start with no provisional card."""
+
+ first_stream = _structured_tool_call("python", {"code": "print(1)"}, "call_py_small")
+ final_stream = [_sse({"content": "Done."}), _done()]
+ payloads: list[dict] = []
+ backend = _make_backend(monkeypatch, [first_stream, final_stream], payloads)
+
+ monkeypatch.setattr("core.inference.tools.execute_tool", lambda *_a, **_k: "OK")
+
+ events = list(
+ backend.generate_chat_completion_with_tools(
+ messages = [{"role": "user", "content": "x"}],
+ tools = [{"type": "function", "function": {"name": "python"}}],
+ max_tool_iterations = 1,
+ )
+ )
+
+ tool_starts = [e for e in events if e.get("type") == "tool_start"]
+ assert [e for e in tool_starts if not e.get("arguments")] == []
+ assert len([e for e in tool_starts if e.get("arguments", {}).get("code")]) == 1
+
+
+def _streamed_parallel_tool_calls(specs, frag: int = 24) -> list[str]:
+ """Two or more structured tool calls, each streamed token-by-token across
+ deltas, one index fully before the next, mirroring how llama-server streams
+ several parallel tool calls whose arguments are large."""
+ chunks: list[str] = []
+ for index, (tool_name, arguments, call_id) in enumerate(specs):
+ args_json = json.dumps(arguments)
+ fragments = [args_json[i : i + frag] for i in range(0, len(args_json), frag)] or [""]
+ chunks.append(
+ _sse(
+ {
+ "tool_calls": [
+ {
+ "index": index,
+ "id": call_id,
+ "type": "function",
+ "function": {"name": tool_name, "arguments": fragments[0]},
+ }
+ ]
+ }
+ )
+ )
+ for fragment in fragments[1:]:
+ chunks.append(
+ _sse({"tool_calls": [{"index": index, "function": {"arguments": fragment}}]})
+ )
+ chunks.append(_done())
+ return chunks
+
+
+def test_parallel_large_tool_calls_each_emit_provisional_start(monkeypatch):
+ """With parallel tool use enabled (the default), every streamed large tool
+ call surfaces its own provisional card, not just the first one, so the UI
+ shows progress for each call as its arguments stream."""
+
+ big_code = "total = 0\n" + "\n".join(f"total += {i}" for i in range(120))
+ big_cmd = "echo start\n" + "\n".join(f"echo line {i}" for i in range(60))
+ assert len(json.dumps({"code": big_code})) > _PROVISIONAL_ARGS_MIN_CHARS
+ assert len(json.dumps({"command": big_cmd})) > _PROVISIONAL_ARGS_MIN_CHARS
+
+ first_stream = _streamed_parallel_tool_calls(
+ [
+ ("python", {"code": big_code}, "call_py"),
+ ("terminal", {"command": big_cmd}, "call_term"),
+ ]
+ )
+ final_stream = [_sse({"content": "Done."}), _done()]
+ payloads: list[dict] = []
+ backend = _make_backend(monkeypatch, [first_stream, final_stream], payloads)
+
+ calls: list[tuple[str, dict]] = []
+
+ def fake_execute_tool(name, arguments, **_kwargs):
+ calls.append((name, arguments))
+ return "OK"
+
+ monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
+
+ events = list(
+ backend.generate_chat_completion_with_tools(
+ messages = [{"role": "user", "content": "do both"}],
+ tools = [
+ {"type": "function", "function": {"name": "python"}},
+ {"type": "function", "function": {"name": "terminal"}},
+ ],
+ max_tool_iterations = 1,
+ )
+ )
+
+ provisional = [e for e in events if e.get("type") == "tool_start" and not e.get("arguments")]
+ assert sorted(e["tool_call_id"] for e in provisional) == ["call_py", "call_term"]
+ assert all(e["provenance"].get("provisional") is True for e in provisional)
+ # Both calls actually executed (parallel tool use is enabled by default).
+ assert sorted(name for name, _ in calls) == ["python", "terminal"]
+
+
+def test_parallel_disabled_suppresses_provisional_for_later_calls(monkeypatch):
+ """When parallel tool use is disabled the downstream truncates to the first
+ call, so only the first streamed call may surface a provisional; a later
+ call must not get a card that could never reconcile or be closed."""
+
+ big_code = "total = 0\n" + "\n".join(f"total += {i}" for i in range(120))
+ big_cmd = "echo start\n" + "\n".join(f"echo line {i}" for i in range(60))
+
+ first_stream = _streamed_parallel_tool_calls(
+ [
+ ("python", {"code": big_code}, "call_py"),
+ ("terminal", {"command": big_cmd}, "call_term"),
+ ]
+ )
+ final_stream = [_sse({"content": "Done."}), _done()]
+ payloads: list[dict] = []
+ backend = _make_backend(monkeypatch, [first_stream, final_stream], payloads)
+
+ calls: list[tuple[str, dict]] = []
+
+ def fake_execute_tool(name, arguments, **_kwargs):
+ calls.append((name, arguments))
+ return "OK"
+
+ monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
+
+ events = list(
+ backend.generate_chat_completion_with_tools(
+ messages = [{"role": "user", "content": "do both"}],
+ tools = [
+ {"type": "function", "function": {"name": "python"}},
+ {"type": "function", "function": {"name": "terminal"}},
+ ],
+ max_tool_iterations = 1,
+ disable_parallel_tool_use = True,
+ )
+ )
+
+ provisional = [e for e in events if e.get("type") == "tool_start" and not e.get("arguments")]
+ assert [e["tool_call_id"] for e in provisional] == ["call_py"]
+ # Only the first call executes when parallel use is disabled.
+ assert calls == [("python", {"code": big_code})]
+ # The lone provisional is closed exactly once (no dangling card).
+ closing = [
+ e for e in events if e.get("type") == "tool_end" and e.get("tool_call_id") == "call_py"
+ ]
+ assert len(closing) == 1
+
+
+def test_connect_error_during_tool_call_closes_provisional_card(monkeypatch):
+ """If llama-server drops mid tool-call after a provisional card is shown, the
+ loop must close that card before surfacing the error so the UI never leaves a
+ tool spinning forever."""
+ import httpx
+
+ big_code = "total = 0\n" + "\n".join(f"total += {i}" for i in range(120))
+ fragments = _streamed_structured_tool_call("python", {"code": big_code}, "call_py_err")
+ # Drop the trailing [DONE]; raise a connection error after the fragments
+ # stream (and after the provisional card has been emitted).
+ fragments = fragments[:-1]
+
+ def raising_stream():
+ for chunk in fragments:
+ yield chunk
+ raise httpx.ConnectError("connection lost mid stream")
+
+ payloads: list[dict] = []
+ backend = _make_backend(monkeypatch, [raising_stream()], payloads)
+
+ monkeypatch.setattr("core.inference.tools.execute_tool", lambda *_a, **_k: "OK")
+
+ collected: list[dict] = []
+ raised = False
+ gen = backend.generate_chat_completion_with_tools(
+ messages = [{"role": "user", "content": "write code"}],
+ tools = [{"type": "function", "function": {"name": "python"}}],
+ max_tool_iterations = 1,
+ )
+ try:
+ for event in gen:
+ collected.append(event)
+ except RuntimeError as exc:
+ raised = True
+ assert "Lost connection" in str(exc)
+
+ assert raised
+ provisional = [e for e in collected if e.get("type") == "tool_start" and not e.get("arguments")]
+ assert len(provisional) == 1
+ assert provisional[0]["tool_call_id"] == "call_py_err"
+ # The provisional card is closed before the error propagates.
+ closing = [
+ e
+ for e in collected
+ if e.get("type") == "tool_end" and e.get("tool_call_id") == "call_py_err"
+ ]
+ assert len(closing) == 1
+ # The closing card is marked as an error, not an empty success, so the UI
+ # renders it as failed.
+ assert "Error" in (closing[0].get("result") or "")
+
+
+def test_empty_tool_call_id_does_not_emit_provisional_card(monkeypatch):
+ """llama.cpp can stream a tool call whose id is an empty string. A provisional
+ card keyed by "" cannot reconcile with the real tool_start (the frontend mints
+ its own id per event), so it must not be emitted -- otherwise the empty card
+ would dangle. The real call must still execute normally."""
+
+ big_code = "total = 0\n" + "\n".join(f"total += {i}" for i in range(120))
+ assert len(json.dumps({"code": big_code})) > _PROVISIONAL_ARGS_MIN_CHARS
+
+ # Same large streamed call as the provisional test, but with an empty id.
+ first_stream = _streamed_structured_tool_call("python", {"code": big_code}, "")
+ final_stream = [_sse({"content": "Done."}), _done()]
+ payloads: list[dict] = []
+ backend = _make_backend(monkeypatch, [first_stream, final_stream], payloads)
+
+ calls: list[tuple[str, dict]] = []
+
+ def fake_execute_tool(name, arguments, **_kwargs):
+ calls.append((name, arguments))
+ return "OK"
+
+ monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
+
+ events = list(
+ backend.generate_chat_completion_with_tools(
+ messages = [{"role": "user", "content": "write code"}],
+ tools = [{"type": "function", "function": {"name": "python"}}],
+ max_tool_iterations = 1,
+ )
+ )
+
+ # No provisional card (empty-args tool_start) was surfaced for the empty id.
+ provisional = [e for e in events if e.get("type") == "tool_start" and not e.get("arguments")]
+ assert provisional == []
+ # The real call still executes despite the missing id.
+ assert calls == [("python", {"code": big_code})]
diff --git a/studio/backend/tests/test_safetensors_tool_loop.py b/studio/backend/tests/test_safetensors_tool_loop.py
index 4098c87f4b..d61c0b389c 100644
--- a/studio/backend/tests/test_safetensors_tool_loop.py
+++ b/studio/backend/tests/test_safetensors_tool_loop.py
@@ -393,6 +393,138 @@ class TestLoopBasic:
assert exec_fn.calls[0][0] == "render_html"
assert "" in exec_fn.calls[0][1]["code"]
+ def test_render_html_confirmation_gate_suppresses_early_provisional(self, monkeypatch):
+ """When a human confirmation gate is active, render_html must not surface
+ an early provisional tool_start: that card (keyed by tool_call_id, no
+ approval) would show the tool 'running' before the user approves. The
+ gated real tool_start is the first signal the UI receives instead."""
+ monkeypatch.setattr(safetensors_agentic, "new_approval_id", lambda: "approval-rh")
+ monkeypatch.setattr(safetensors_agentic, "begin_tool_decision", lambda *_a, **_k: object())
+ monkeypatch.setattr(safetensors_agentic, "wait_tool_decision", lambda *_a, **_k: "allow")
+
+ exec_fn = FakeExecuteTool(["Rendered HTML canvas."])
+ turn_iter = iter(
+ [
+ [
+ "",
+ "",
+ "Hi",
+ ],
+ ["Done."],
+ ]
+ )
+
+ def _gen(_messages):
+ chunks = next(turn_iter)
+ acc = ""
+ for chunk in chunks:
+ acc += chunk
+ yield acc
+
+ loop = run_safetensors_tool_loop(
+ single_turn = _gen,
+ messages = [{"role": "user", "content": "make html"}],
+ tools = [{"type": "function", "function": {"name": "render_html"}}],
+ execute_tool = exec_fn,
+ confirm_tool_calls = True,
+ session_id = "sess",
+ max_tool_iterations = 3,
+ )
+ events = _collect_events(loop)
+ tool_starts = [e for e in events if e["type"] == "tool_start"]
+
+ # No early provisional (empty-args) card while confirmation is pending.
+ assert [e for e in tool_starts if e.get("arguments") == {}] == []
+ # The real, gated tool_start still surfaces with the full arguments.
+ real = [e for e in tool_starts if e.get("arguments", {}).get("code")]
+ assert len(real) == 1
+ assert real[0].get("awaiting_confirmation") is True
+ assert "" in real[0]["arguments"]["code"]
+ assert exec_fn.calls[0][0] == "render_html"
+
+ def test_render_html_bypass_permissions_keeps_early_provisional(self, monkeypatch):
+ """bypass_permissions wins over the confirm gate, so the early provisional
+ card is preserved (no human approval is required)."""
+ exec_fn = FakeExecuteTool(["Rendered HTML canvas."])
+ turn_iter = iter(
+ [
+ [
+ "",
+ "",
+ "Hi",
+ ],
+ ["Done."],
+ ]
+ )
+
+ def _gen(_messages):
+ chunks = next(turn_iter)
+ acc = ""
+ for chunk in chunks:
+ acc += chunk
+ yield acc
+
+ loop = run_safetensors_tool_loop(
+ single_turn = _gen,
+ messages = [{"role": "user", "content": "make html"}],
+ tools = [{"type": "function", "function": {"name": "render_html"}}],
+ execute_tool = exec_fn,
+ confirm_tool_calls = True,
+ bypass_permissions = True,
+ session_id = "sess",
+ max_tool_iterations = 3,
+ )
+ events = _collect_events(loop)
+ tool_starts = [e for e in events if e["type"] == "tool_start"]
+
+ assert len(tool_starts) == 2
+ assert tool_starts[0]["arguments"] == {}
+ assert "" in tool_starts[1]["arguments"]["code"]
+
+ def test_render_html_provisional_card_closed_on_generator_exception(self):
+ """If the model generator raises mid-stream after a provisional render_html
+ card was surfaced, the loop must close that card as errored before the
+ exception propagates, so the UI never leaves a tool spinning forever."""
+ exec_fn = FakeExecuteTool([])
+
+ def _gen(_messages):
+ acc = ""
+ for chunk in ["", ""]:
+ acc += chunk
+ yield acc
+ raise RuntimeError("model pipeline exploded")
+
+ loop = run_safetensors_tool_loop(
+ single_turn = _gen,
+ messages = [{"role": "user", "content": "make html"}],
+ tools = [{"type": "function", "function": {"name": "render_html"}}],
+ execute_tool = exec_fn,
+ )
+
+ collected: list[dict] = []
+ raised = False
+ try:
+ for event in loop:
+ collected.append(event)
+ except RuntimeError as exc:
+ raised = True
+ assert "exploded" in str(exc)
+
+ assert raised
+ provisional = [
+ e for e in collected if e["type"] == "tool_start" and e.get("arguments") == {}
+ ]
+ assert len(provisional) == 1
+ # The provisional card is closed (as an error) before the exception
+ # propagates, so it never dangles.
+ closing = [
+ e
+ for e in collected
+ if e["type"] == "tool_end" and e.get("tool_call_id") == provisional[0]["tool_call_id"]
+ ]
+ assert len(closing) == 1
+ assert "Error" in (closing[0].get("result") or "")
+
def test_python_tool_containing_render_html_signal_does_not_emit_provisional_start(self):
loop, exec_fn = _make_loop(
turns = [