tag"}}]}',
+ 'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
+ "data: [DONE]",
+ ]
+ chunks = await _drive_stream(monkeypatch, _payload(), lines)
+ payloads = _stream_payloads(chunks)
+ text = "".join(
+ (c.get("delta") or {}).get("content") or ""
+ for p in payloads
+ for c in p.get("choices", [])
+ )
+ assert text == "use the
tag"
+ finishes = [
+ c["finish_reason"]
+ for p in payloads
+ for c in p.get("choices", [])
+ if c.get("finish_reason")
+ ]
+ assert finishes == ["stop"]
+
+ asyncio.run(_run())
+
+ def test_incomplete_xml_healed_at_done(self, monkeypatch):
+ async def _run():
+ # No close tag and no finish chunk: healed at the [DONE] boundary,
+ # synthetic finish must say tool_calls.
+ lines = [
+ 'data: {"id":"c1","choices":[{"index":0,"delta":{"content":"
{\\"name\\":\\"lookup\\",\\"arguments\\":{}}"}}]}',
+ "data: [DONE]",
+ ]
+ chunks = await _drive_stream(monkeypatch, _payload(), lines)
+ payloads = _stream_payloads(chunks)
+ tool_deltas = [
+ tc
+ for p in payloads
+ for c in p.get("choices", [])
+ for tc in (c.get("delta") or {}).get("tool_calls") or []
+ ]
+ assert len(tool_deltas) == 1
+ finishes = [
+ c["finish_reason"]
+ for p in payloads
+ for c in p.get("choices", [])
+ if c.get("finish_reason")
+ ]
+ assert finishes == ["tool_calls"]
+
+ asyncio.run(_run())
+
+ def test_structured_upstream_calls_relay_verbatim(self, monkeypatch):
+ async def _run():
+ line = (
+ 'data: {"id":"c1","choices":[{"index":0,"delta":{"tool_calls":'
+ '[{"index":0,"id":"call_up","type":"function","function":'
+ '{"name":"lookup","arguments":"{}"}}]}}]}'
+ )
+ lines = [
+ line,
+ 'data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
+ "data: [DONE]",
+ ]
+ chunks = await _drive_stream(monkeypatch, _payload(), lines)
+ assert chunks[0] == line + "\n\n" # byte-for-byte relay
+
+ asyncio.run(_run())
diff --git a/studio/backend/tests/test_responses_tool_passthrough.py b/studio/backend/tests/test_responses_tool_passthrough.py
index 4147746b54..a7ceb49ed9 100644
--- a/studio/backend/tests/test_responses_tool_passthrough.py
+++ b/studio/backend/tests/test_responses_tool_passthrough.py
@@ -1986,3 +1986,177 @@ class TestTranslatedMessagesValidate:
msgs = _normalise_responses_input(payload)
for m in msgs:
ChatMessage(**m.model_dump(exclude_none = True))
+
+
+# =====================================================================
+# Streaming passthrough healing — text-form calls promoted in order
+# =====================================================================
+
+
+class TestResponsesStreamHealing:
+ """Route-level healing on the /v1/responses stream: text-form tool calls
+ are promoted through the same per-call item state machinery as structured
+ deltas, and healer events keep their order (text around a healed call must
+ not move relative to the function_call item)."""
+
+ _XML = '{"name":"lookup","arguments":{"q":"x"}}'
+ _TOOL = {"type": "function", "name": "lookup", "parameters": {"type": "object"}}
+
+ @staticmethod
+ def _ordered_events(lines):
+ events = []
+ for line in lines:
+ if not line.startswith("event: "):
+ continue
+ name, _, rest = line.partition("\n")
+ payload = json.loads(rest.split("data: ", 1)[1].strip())
+ events.append((name[len("event: ") :], payload))
+ return events
+
+ def _run_stream(self, monkeypatch, content, **payload_kwargs):
+ TestResponsesStreamAdapter._install_stream_mock(
+ monkeypatch, [{"choices": [{"delta": {"content": content}}]}]
+ )
+ payload = ResponsesRequest(input = "hi", stream = True, tools = [self._TOOL], **payload_kwargs)
+ messages = [ChatMessage(role = "user", content = "hi")]
+
+ async def run():
+ response = await _responses_stream(
+ payload, messages, TestResponsesStreamAdapter._Request()
+ )
+ return await TestResponsesStreamAdapter._collect(response)
+
+ return self._ordered_events(asyncio.run(run()))
+
+ def test_text_around_healed_call_keeps_order(self, monkeypatch):
+ events = self._run_stream(monkeypatch, f"before {self._XML} after.")
+ pos_before = pos_item = pos_after = None
+ for i, (name, payload) in enumerate(events):
+ if name == "response.output_text.delta":
+ if "before" in payload["delta"] and pos_before is None:
+ pos_before = i
+ if "after" in payload["delta"]:
+ pos_after = i
+ if (
+ name == "response.output_item.added"
+ and payload["item"]["type"] == "function_call"
+ and pos_item is None
+ ):
+ pos_item = i
+ assert payload["item"]["name"] == "lookup"
+ assert pos_before is not None and pos_item is not None and pos_after is not None
+ assert pos_before < pos_item < pos_after
+
+ def test_call_before_trailing_text_claims_lower_output_index(self, monkeypatch):
+ events = self._run_stream(monkeypatch, f"{self._XML} done.")
+ item_added = [
+ (name, payload) for name, payload in events if name == "response.output_item.added"
+ ]
+ # The call came first in the model output, so its item is added first
+ # and claims the lower output_index; the trailing text's message item
+ # follows.
+ assert [payload["item"]["type"] for _, payload in item_added] == [
+ "function_call",
+ "message",
+ ]
+ call_idx = item_added[0][1]["output_index"]
+ msg_idx = item_added[1][1]["output_index"]
+ assert call_idx < msg_idx
+ text = "".join(
+ payload["delta"] for name, payload in events if name == "response.output_text.delta"
+ )
+ assert "done." in text
+ assert "" not in text
+
+ def test_tool_choice_none_streams_raw_text(self, monkeypatch):
+ events = self._run_stream(monkeypatch, self._XML, tool_choice = "none")
+ assert not any(
+ payload["item"]["type"] == "function_call"
+ for name, payload in events
+ if name == "response.output_item.added"
+ )
+ text = "".join(
+ payload["delta"] for name, payload in events if name == "response.output_text.delta"
+ )
+ assert text == self._XML
+
+ def test_healed_call_splits_message_items(self, monkeypatch):
+ # Text on both sides of a healed call becomes TWO message items: the
+ # healed function_call closes the first, trailing text opens a fresh
+ # one with a later output index (native Responses stream shape).
+ events = self._run_stream(monkeypatch, f"before {self._XML} after.")
+ added = [
+ (payload["output_index"], payload["item"]["type"], payload["item"].get("id"))
+ for name, payload in events
+ if name == "response.output_item.added"
+ ]
+ assert [item_type for _, item_type, _ in added] == [
+ "message",
+ "function_call",
+ "message",
+ ]
+ assert [idx for idx, _, _ in added] == sorted(idx for idx, _, _ in added)
+ assert added[0][2] != added[2][2] # distinct message item ids
+ # Text deltas attribute to their OWN message item.
+ deltas = [
+ (payload["item_id"], payload["delta"])
+ for name, payload in events
+ if name == "response.output_text.delta"
+ ]
+ assert [d for i, d in deltas if i == added[0][2]] == ["before "]
+ assert [d for i, d in deltas if i == added[2][2]] == [" after."]
+ # The completed snapshot lists all three items with per-item text.
+ completed = [payload for name, payload in events if name == "response.completed"]
+ output = completed[0]["response"]["output"]
+ assert [item["type"] for item in output] == ["message", "function_call", "message"]
+ assert output[0]["content"][0]["text"] == "before "
+ assert output[2]["content"][0]["text"] == " after."
+
+ def test_parallel_cap_drops_native_after_healed(self, monkeypatch):
+ # parallel_tool_calls=false: a healed call consumed the single allowed
+ # slot; a later native structured call (index 0, so it survives
+ # _drop_parallel_tool_call_deltas) must not open a second
+ # function_call item.
+ TestResponsesStreamAdapter._install_stream_mock(
+ monkeypatch,
+ [
+ {"choices": [{"delta": {"content": self._XML}}]},
+ {
+ "choices": [
+ {
+ "delta": {
+ "tool_calls": [
+ {
+ "index": 0,
+ "id": "call_up",
+ "function": {"name": "lookup", "arguments": "{}"},
+ }
+ ]
+ }
+ }
+ ]
+ },
+ ],
+ )
+ payload = ResponsesRequest(
+ input = "hi",
+ stream = True,
+ tools = [self._TOOL],
+ parallel_tool_calls = False,
+ )
+ messages = [ChatMessage(role = "user", content = "hi")]
+
+ async def run():
+ response = await _responses_stream(
+ payload, messages, TestResponsesStreamAdapter._Request()
+ )
+ return await TestResponsesStreamAdapter._collect(response)
+
+ events = self._ordered_events(asyncio.run(run()))
+ calls = [
+ payload
+ for name, payload in events
+ if name == "response.output_item.added" and payload["item"]["type"] == "function_call"
+ ]
+ assert len(calls) == 1
+ assert calls[0]["item"]["name"] == "lookup"
diff --git a/studio/backend/tests/test_tool_call_parser_strict.py b/studio/backend/tests/test_tool_call_parser_strict.py
index 931d8a705d..39fdd151be 100644
--- a/studio/backend/tests/test_tool_call_parser_strict.py
+++ b/studio/backend/tests/test_tool_call_parser_strict.py
@@ -71,6 +71,28 @@ class TestFunctionStyleTrailingText:
call = _only(text)
assert call == {"name": "python", "arguments": {"code": 'print("")'}}
+ def test_closed_function_with_trailing_prose_heal_path(self):
+ # Regression: the heal / finalize path (allow_incomplete=True) used to fold
+ # and the trailing prose into the argument and drop
+ # the prose from visible content. It must now match the strict path -- keep a
+ # clean argument and leave the trailing prose outside the call span.
+ text = "cats trailing words"
+ calls = parse_tool_calls_from_text(text, allow_incomplete = True)
+ assert len(calls) == 1
+ fn = calls[0]["function"]
+ assert fn["name"] == "web_search"
+ assert json.loads(fn["arguments"]) == {"query": "cats"}
+ # The trailing prose sits outside the removed span, so it stays visible.
+ from core.tool_healing import (
+ parse_tool_calls_from_text as _parse_with_spans,
+ )
+
+ _calls, spans = _parse_with_spans(text, allow_incomplete = True, with_spans = True)
+ out = text
+ for s, e in sorted(spans, reverse = True):
+ out = out[:s] + out[e:]
+ assert out == " trailing words"
+
def test_incomplete_function_without_close_is_still_rejected(self):
text = "weather london"
assert parse_tool_calls_from_text(text, allow_incomplete = False) == []
@@ -160,3 +182,18 @@ class TestHealingPathUnaffected:
calls = parse_tool_calls_from_text(text, allow_incomplete = True)
assert len(calls) == 1
assert calls[0]["function"]["name"] == "web_search"
+
+ def test_closed_function_call_keeps_trailing_prose_out_of_arguments(self):
+ # allow_incomplete exists for truncated output; a call that DID close
+ # must parse identically to strict mode, leaving prose after
+ # out of the last parameter and out of the removal span.
+ from core.tool_healing import parse_tool_calls_from_text as parse_with_spans
+
+ text = "cats trailing"
+ calls, spans = parse_with_spans(text, allow_incomplete = True, with_spans = True)
+ (call,) = calls
+ assert json.loads(call["function"]["arguments"]) == {"query": "cats"}
+ (span,) = spans
+ assert text[span[0] : span[1]] == (
+ "cats"
+ )