From 42159249ea34c7484b4a37ee70229275426b2ec1 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 26 May 2026 12:35:52 +0000 Subject: [PATCH 1/5] Studio: agentic web_search action variants + image overlay polish (OpenAI) Backend: dispatch OpenAI Responses web_search_call on action.type ({search, open_page, find_in_page}) so agentic gpt-5.x calls render meaningful per-card labels (`Read `, `Find "" in `) instead of empty quotes. Probe action.queries[0]/item.query as fallbacks for older shapes. Backfill query from any prior event for the same id. Frontend: web-search card renders all three action variants in both collapsed trigger and running states; falls back to `Searching...` when nothing is known. Defensive scrubber in MarkdownText strips any leftover U+E200/U+E201/U+E202 citation markers that survive the backend rewriter (e.g. SSE dropped before the end-of-stream flush). Image overlay: download + close buttons enlarged to size-10 with green primary styling so they match the "Type edits below, then send" pill. Image generation Show more/less now uses ChevronRight/Down icons to match the other tool cards. Tests: 3 new web_search action-variant tests; 55/55 pass. --- .../core/inference/external_provider.py | 86 +++++++++++++------ .../test_openai_tool_result_fallbacks.py | 74 ++++++++++++++++ .../components/assistant-ui/markdown-text.tsx | 15 +++- .../src/components/assistant-ui/thread.tsx | 14 +-- .../assistant-ui/tool-ui-image-generation.tsx | 15 +++- .../assistant-ui/tool-ui-web-search.tsx | 32 +++++-- 6 files changed, 192 insertions(+), 44 deletions(-) diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index 8f34bb23fc..6968225475 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -243,6 +243,52 @@ def _split_pending_citation_tail(text: str) -> tuple[str, str]: return text[:last_open], text[last_open:] +def _extract_web_search_action(item: dict[str, Any]) -> dict[str, Any]: + """Normalise web_search_call.action across variants. gpt-5.x agentic + search emits action.type in {search, open_page, find_in_page}; older + variants may put query at item.query or use action.queries[0]. + Returns only non-empty keys among query/url/pattern/action_type. + """ + if not isinstance(item, dict): + return {} + action = item.get("action") if isinstance(item.get("action"), dict) else {} + atype = action.get("type") if isinstance(action.get("type"), str) else "" + query = action.get("query") if isinstance(action.get("query"), str) else "" + if not query: + # Fallbacks: action.queries[0], item.query, item.queries[0]. + for src in (action.get("queries"), item.get("queries")): + if isinstance(src, list) and src and isinstance(src[0], str) and src[0]: + query = src[0] + break + if not query and isinstance(item.get("query"), str): + query = item["query"] + url = action.get("url") if isinstance(action.get("url"), str) else "" + pattern = action.get("pattern") if isinstance(action.get("pattern"), str) else "" + out: dict[str, Any] = {} + if query: out["query"] = query + if url: out["url"] = url + if pattern: out["pattern"] = pattern + if atype: out["action_type"] = atype + return out + + +def _web_search_card_text(args: dict[str, Any]) -> str: + """Single-line summary for the per-card tool_end result.""" + if not args: + return "" + q, u, p = args.get("query") or "", args.get("url") or "", args.get("pattern") or "" + atype = args.get("action_type") or "" + if atype == "open_page" and u: + return f"Read: {u}" + if atype == "find_in_page" and u: + return f"Find {p!r} in {u}" if p else f"Find in {u}" + if q: + return f"Searching: {q}" + if u: + return f"Read: {u}" + return "" + + class _AnthropicThinkingSpec(NamedTuple): prefixes: tuple[str, ...] kind: Literal["adaptive", "manual"] @@ -3844,45 +3890,35 @@ class ExternalProviderClient: yield _chunk_with_text(summary_text) reasoning_emitted = True elif item.get("type") == "web_search_call": - # done is the canonical place to read the - # query, so emit both tool_start and tool_end - # here. Frontend then renders a card per call - # with the proper "Searching: " label. - # Citations are aggregated separately and the - # *last* call's result is overwritten at - # response.completed with the citation list - # (so the source-pill extraction at message - # tail surfaces them once). + # Dispatch on action.type so open_page / + # find_in_page render url+pattern instead + # of an empty query card. Last call's + # result is overwritten with citations at + # response.completed. item_id = item.get("id", "") or ( f"ws_{len(web_search_calls)}" ) - action = item.get("action") - query = ( - action.get("query", "") - if isinstance(action, dict) - else "" - ) - web_search_calls[item_id] = {"query": query} + args = _extract_web_search_action(item) + # Backfill query from any prior event for this id. + if not args.get("query"): + prior = web_search_calls.get(item_id) or {} + prior_q = prior.get("query") if isinstance(prior, dict) else "" + if isinstance(prior_q, str) and prior_q: + args["query"] = prior_q + web_search_calls[item_id] = dict(args) yield _emit_tool_event( { "type": "tool_start", "tool_name": "web_search", "tool_call_id": item_id, - "arguments": ( - {"query": query} if query else {} - ), + "arguments": args, } ) - # Per-card text; last call gets overwritten - # with citations at response.completed. - per_call_result = ( - f"Searching: {query}" if query else "" - ) yield _emit_tool_event( { "type": "tool_end", "tool_call_id": item_id, - "result": per_call_result, + "result": _web_search_card_text(args), } ) elif item.get("type") == "shell_call": diff --git a/studio/backend/tests/test_openai_tool_result_fallbacks.py b/studio/backend/tests/test_openai_tool_result_fallbacks.py index 7c033bc348..5d8fa947bd 100644 --- a/studio/backend/tests/test_openai_tool_result_fallbacks.py +++ b/studio/backend/tests/test_openai_tool_result_fallbacks.py @@ -200,6 +200,80 @@ def test_web_search_empty_query_falls_back_to_empty_result(monkeypatch): assert ends[0]["result"] == "" +def test_web_search_open_page_action_renders_url(monkeypatch): + """gpt-5.x agentic search emits `open_page` with a url (no query).""" + sse_events = [ + { + "type": "response.output_item.done", + "item": { + "type": "web_search_call", + "id": "ws_open", + "action": { + "type": "open_page", + "url": "https://en.wikipedia.org/wiki/Tiger", + }, + }, + }, + {"type": "response.completed", "response": {}}, + ] + lines = _drive_stream(sse_events, ["web_search"], monkeypatch) + events = _tool_events(lines) + starts = [e for e in events if e["type"] == "tool_start"] + ends = [e for e in events if e["type"] == "tool_end"] + assert starts[0]["arguments"]["url"] == "https://en.wikipedia.org/wiki/Tiger" + assert starts[0]["arguments"]["action_type"] == "open_page" + assert "Read: https://en.wikipedia.org/wiki/Tiger" in ends[0]["result"] + + +def test_web_search_find_in_page_action_renders_url_and_pattern(monkeypatch): + """`find_in_page` actions surface both url and pattern to the card.""" + sse_events = [ + { + "type": "response.output_item.done", + "item": { + "type": "web_search_call", + "id": "ws_find", + "action": { + "type": "find_in_page", + "url": "https://en.wikipedia.org/wiki/Tiger", + "pattern": "population", + }, + }, + }, + {"type": "response.completed", "response": {}}, + ] + lines = _drive_stream(sse_events, ["web_search"], monkeypatch) + events = _tool_events(lines) + starts = [e for e in events if e["type"] == "tool_start"] + ends = [e for e in events if e["type"] == "tool_end"] + assert starts[0]["arguments"]["url"] == "https://en.wikipedia.org/wiki/Tiger" + assert starts[0]["arguments"]["pattern"] == "population" + assert starts[0]["arguments"]["action_type"] == "find_in_page" + assert "population" in ends[0]["result"] + assert "en.wikipedia.org" in ends[0]["result"] + + +def test_web_search_action_queries_plural_falls_back(monkeypatch): + """`action.queries[0]` is used when `action.query` is absent (older shape).""" + sse_events = [ + { + "type": "response.output_item.done", + "item": { + "type": "web_search_call", + "id": "ws_plural", + "action": {"queries": ["renewable energy 2026"]}, + }, + }, + {"type": "response.completed", "response": {}}, + ] + lines = _drive_stream(sse_events, ["web_search"], monkeypatch) + events = _tool_events(lines) + starts = [e for e in events if e["type"] == "tool_start"] + ends = [e for e in events if e["type"] == "tool_end"] + assert starts[0]["arguments"]["query"] == "renewable energy 2026" + assert ends[0]["result"] == "Searching: renewable energy 2026" + + # ── shell_call output fallbacks ──────────────────────────────────────── diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index d2c6208fda..a4beff925c 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -411,9 +411,22 @@ function StreamdownBlock(props: BlockProps) { } const AUDIO_PLAYER_RE = //; +// Defensive scrub: strip any OpenAI cite markers that escape the +// backend rewriter (e.g. SSE dropped before end-of-stream flush). +const OPENAI_CITE_MARKER_RE = /cite[^]*/g; +const OPENAI_PUA_ORPHAN_RE = /[]/g; +function scrubOpenAICitationMarkers(text: string): string { + if (!text) return text; + if (!text.includes("") && !text.includes("")) return text; + return text.replace(OPENAI_CITE_MARKER_RE, "").replace(OPENAI_PUA_ORPHAN_RE, ""); +} + const MarkdownTextImpl = () => { const { text, status } = useMessagePartText(); - const processedText = useMemo(() => preprocessLaTeX(text), [text]); + const processedText = useMemo( + () => preprocessLaTeX(scrubOpenAICitationMarkers(text)), + [text], + ); const audioMatch = text.match(AUDIO_PLAYER_RE); if (audioMatch) { diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index da243f37e1..9c15b7fddd 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -232,12 +232,12 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({ >
-
+
diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx index 7dfdd903fe..6c054b31c1 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx @@ -6,7 +6,13 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; -import { DownloadIcon, ImageIcon, PencilIcon } from "lucide-react"; +import { + ChevronDownIcon, + ChevronRightIcon, + DownloadIcon, + ImageIcon, + PencilIcon, +} from "lucide-react"; import type { CSSProperties, MouseEvent } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { useGeneratedImageOverlay } from "./generated-image-overlay-context"; @@ -348,7 +354,7 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({ {promptCanExpand ? ( ) : null} diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx index b9bda2e832..f23cb8dc18 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx @@ -70,7 +70,11 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({ }) => { const query = (args as { query?: string })?.query ?? ""; const url = ((args as { url?: string })?.url ?? "").trim(); - const isUrlFetch = !!url; + const pattern = (args as { pattern?: string })?.pattern ?? ""; + const actionType = (args as { action_type?: string })?.action_type ?? ""; + // gpt-5.x agentic action.type: search | open_page | find_in_page. + const isFindInPage = actionType === "find_in_page" || (!!url && !!pattern); + const isUrlFetch = !!url && !isFindInPage; const displayDomain = (() => { if (!url) return ""; try { @@ -105,11 +109,15 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({ - {isUrlFetch - ? <>Reading {displayDomain || "page"}… - : <>Searching for “{query}”… + {isFindInPage + ? pattern + ? <>Finding “{pattern}” in {displayDomain || "page"}… + : <>Searching {displayDomain || "page"}… + : isUrlFetch + ? <>Reading {displayDomain || "page"}… + : query + ? <>Searching for “{query}”… + : <>Searching… }
From 0894a1fad8a4710c8558a59f03a9eb8cdd2b6b10 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 12:36:54 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../core/inference/external_provider.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index 6968225475..a28bced444 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -265,10 +265,14 @@ def _extract_web_search_action(item: dict[str, Any]) -> dict[str, Any]: url = action.get("url") if isinstance(action.get("url"), str) else "" pattern = action.get("pattern") if isinstance(action.get("pattern"), str) else "" out: dict[str, Any] = {} - if query: out["query"] = query - if url: out["url"] = url - if pattern: out["pattern"] = pattern - if atype: out["action_type"] = atype + if query: + out["query"] = query + if url: + out["url"] = url + if pattern: + out["pattern"] = pattern + if atype: + out["action_type"] = atype return out @@ -3902,7 +3906,11 @@ class ExternalProviderClient: # Backfill query from any prior event for this id. if not args.get("query"): prior = web_search_calls.get(item_id) or {} - prior_q = prior.get("query") if isinstance(prior, dict) else "" + prior_q = ( + prior.get("query") + if isinstance(prior, dict) + else "" + ) if isinstance(prior_q, str) and prior_q: args["query"] = prior_q web_search_calls[item_id] = dict(args) From 9807cdf039ec63ad638125b20dcf573eb2a18246 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 26 May 2026 12:53:39 +0000 Subject: [PATCH 3/5] Studio: surface per-call web_search sources, full shell command, exit_code, transparent image overlay Backend (external_provider.py): - Request `include=[web_search_call.action.sources, web_search_call.results]` so each web_search card has its consulted URLs and (for reasoning models) the search-result snippets attached. Format them as the Title/URL/Snippet blocks the frontend's source-pill parser already understands, so every search card now surfaces real sources instead of repeating the trigger label. - _format_shell_output now always emits exit_code (not just non-zero), reads stdout from `stdout`/`text`/`content` for tolerance across shell_call_output revisions, and dumps the raw entry dict as a fallback when no recognised text fields are present so the user still sees what OpenAI sent. Drops the literal "(no output)" string in favour of an empty result; the frontend renders a friendlier placeholder. Frontend: - tool-ui-code-execution.tsx: expanded card now renders the full untruncated command in its own labelled block above the output, so long heredocs clipped in the trigger are reachable. Empty output is rendered as italic "Command completed with no output." instead of showing nothing. - tool-ui-web-search.tsx: removed the
 fallback that just echoed
  the trigger label back; non-search cards now link to the page URL
  when no sources are present.
- thread.tsx: image overlay container is now transparent (no muted
  background, no border ring); the image itself carries the rounded
  corners and the buttons float over its top-right corner so no
  yellow frame appears around narrow images.

Tests: 2 new web_search source-formatting tests; 66/66 pass.
---
 .../core/inference/external_provider.py       | 103 ++++++++++++++----
 .../test_openai_tool_result_fallbacks.py      |  55 ++++++++++
 .../src/components/assistant-ui/thread.tsx    |  18 ++-
 .../assistant-ui/tool-ui-code-execution.tsx   |  44 ++++++--
 .../assistant-ui/tool-ui-web-search.tsx       |  17 +--
 5 files changed, 191 insertions(+), 46 deletions(-)

diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py
index a28bced444..2e0c5e6256 100644
--- a/studio/backend/core/inference/external_provider.py
+++ b/studio/backend/core/inference/external_provider.py
@@ -276,6 +276,41 @@ def _extract_web_search_action(item: dict[str, Any]) -> dict[str, Any]:
     return out
 
 
+def _format_web_search_per_call_sources(
+    results: Any, sources: Any
+) -> str:
+    """Format per-call sources as the Title/URL/Snippet block the
+    frontend's parseSearchResults expects. Prefers `results` (snippet
+    bearing, reasoning models) then `action.sources` (urls only).
+    Returns "" when neither is populated.
+    """
+    blocks: list[str] = []
+    if isinstance(results, list):
+        for r in results:
+            if not isinstance(r, dict):
+                continue
+            url = r.get("url") if isinstance(r.get("url"), str) else ""
+            if not url:
+                continue
+            title = r.get("title") if isinstance(r.get("title"), str) else url
+            snippet = r.get("snippet") or r.get("text") or ""
+            entry = f"Title: {title}\nURL: {url}"
+            if isinstance(snippet, str) and snippet:
+                entry += f"\nSnippet: {snippet}"
+            blocks.append(entry)
+    if not blocks and isinstance(sources, list):
+        for s in sources:
+            url = ""
+            if isinstance(s, str):
+                url = s
+            elif isinstance(s, dict):
+                url = s.get("url") if isinstance(s.get("url"), str) else ""
+            if not url:
+                continue
+            blocks.append(f"Title: {url}\nURL: {url}")
+    return "\n---\n".join(blocks)
+
+
 def _web_search_card_text(args: dict[str, Any]) -> str:
     """Single-line summary for the per-card tool_end result."""
     if not args:
@@ -2235,7 +2270,7 @@ class ExternalProviderClient:
                             parts.append(f"--- stderr ---\n{stderr}")
                         if isinstance(return_code, int) and return_code != 0:
                             parts.append(f"return_code: {return_code}")
-                        return "\n".join(parts) if parts else "(no output)"
+                        return "\n".join(parts) if parts else ""
                     if inner_type == "text_editor_code_execution_result":
                         # view: file content; create: is_file_update flag;
                         # str_replace: diff `lines` list. The matching
@@ -3283,6 +3318,14 @@ class ExternalProviderClient:
                 tools_array.append(_openai_image_generation_tool())
             if tools_array:
                 body["tools"] = tools_array
+            # Opt into the per-call source list (action.sources) and the
+            # per-call snippet list (results, reasoning models only) so
+            # each web_search card can surface what was actually consulted.
+            if "web_search" in enabled_tools:
+                body["include"] = [
+                    "web_search_call.action.sources",
+                    "web_search_call.results",
+                ]
 
         url = f"{self.base_url}/responses"
         completion_id = f"chatcmpl-openai-{model.replace('/', '-')}"
@@ -3317,6 +3360,13 @@ class ExternalProviderClient:
                     attempt_body["tools"] = tools_array_attempt
                 else:
                     attempt_body.pop("tools", None)
+                if "web_search" in enabled_tools:
+                    attempt_body["include"] = [
+                        "web_search_call.action.sources",
+                        "web_search_call.results",
+                    ]
+                else:
+                    attempt_body.pop("include", None)
             return attempt_body
 
         def _is_openai_container_expired_error(error_text: str) -> bool:
@@ -3518,43 +3568,47 @@ class ExternalProviderClient:
                         return f"data: {_json.dumps(chunk)}"
 
                     def _format_shell_output(output: Any) -> str:
-                        """Render an OpenAI `shell_call_output.output` list
-                        as the preformatted text payload the frontend's
-                        CodeExecutionToolUI displays inside a 
. Each
-                        entry has stdout/stderr/outcome — concatenate them
-                        with a separator block per entry and append
-                        `return_code` / `(timeout)` annotations only when
-                        they convey information beyond "succeeded".
+                        """Render OpenAI `shell_call_output.output` for the
+                        CodeExecutionToolUI 
. Each entry has
+                        stdout/stderr/outcome (canonical) or `text`/`content`
+                        (older shapes). When the entry contains no
+                        recognised text fields at all, dump the raw JSON so
+                        the user can see what OpenAI actually returned.
                         """
                         if not isinstance(output, list):
                             return ""
                         parts: list[str] = []
                         for entry in output:
                             if not isinstance(entry, dict):
+                                if isinstance(entry, str) and entry:
+                                    parts.append(entry)
                                 continue
-                            stdout = entry.get("stdout") or ""
+                            stdout = entry.get("stdout") or entry.get("text") or entry.get("content") or ""
                             stderr = entry.get("stderr") or ""
                             outcome = entry.get("outcome") or {}
                             chunk_parts: list[str] = []
-                            if stdout:
+                            if isinstance(stdout, str) and stdout:
                                 chunk_parts.append(stdout)
-                            if stderr:
+                            if isinstance(stderr, str) and stderr:
                                 chunk_parts.append(f"--- stderr ---\n{stderr}")
                             if isinstance(outcome, dict):
                                 outcome_type = outcome.get("type")
                                 if outcome_type == "exit":
                                     exit_code = outcome.get("exit_code")
-                                    if isinstance(exit_code, int) and exit_code != 0:
-                                        chunk_parts.append(f"return_code: {exit_code}")
+                                    if isinstance(exit_code, int):
+                                        chunk_parts.append(f"exit_code: {exit_code}")
                                 elif outcome_type == "timeout":
                                     chunk_parts.append("(timeout)")
+                            if not chunk_parts:
+                                # Unknown shape: surface the raw dict so the
+                                # user can see what OpenAI actually returned.
+                                try:
+                                    chunk_parts.append(_json.dumps(entry, indent=2))
+                                except (TypeError, ValueError):
+                                    pass
                             if chunk_parts:
                                 parts.append("\n".join(chunk_parts))
-                        return (
-                            "\n--- next command ---\n".join(parts)
-                            if parts
-                            else "(no output)"
-                        )
+                        return "\n--- next command ---\n".join(parts)
 
                     def _record_url_citation(payload: dict[str, Any]) -> None:
                         """Append a url_citation onto the shared all_url_citations
@@ -3922,11 +3976,22 @@ class ExternalProviderClient:
                                             "arguments": args,
                                         }
                                     )
+                                    # Per-call sources: prefer the snippet-bearing
+                                    # `results` list (reasoning models only), then
+                                    # fall back to `action.sources` URLs. Formatted
+                                    # as the Title/URL/Snippet block the frontend
+                                    # parser already understands.
+                                    action_obj = (
+                                        item.get("action") if isinstance(item.get("action"), dict) else {}
+                                    ) or {}
+                                    per_call_sources = _format_web_search_per_call_sources(
+                                        item.get("results"), action_obj.get("sources")
+                                    )
                                     yield _emit_tool_event(
                                         {
                                             "type": "tool_end",
                                             "tool_call_id": item_id,
-                                            "result": _web_search_card_text(args),
+                                            "result": per_call_sources or _web_search_card_text(args),
                                         }
                                     )
                                 elif item.get("type") == "shell_call":
diff --git a/studio/backend/tests/test_openai_tool_result_fallbacks.py b/studio/backend/tests/test_openai_tool_result_fallbacks.py
index 5d8fa947bd..e9f8f2a308 100644
--- a/studio/backend/tests/test_openai_tool_result_fallbacks.py
+++ b/studio/backend/tests/test_openai_tool_result_fallbacks.py
@@ -274,6 +274,61 @@ def test_web_search_action_queries_plural_falls_back(monkeypatch):
     assert ends[0]["result"] == "Searching: renewable energy 2026"
 
 
+def test_web_search_per_call_results_formatted_as_source_blocks(monkeypatch):
+    """`results` array (reasoning models) is formatted into Title/URL/Snippet blocks."""
+    sse_events = [
+        {
+            "type": "response.output_item.done",
+            "item": {
+                "type": "web_search_call",
+                "id": "ws_r",
+                "action": {"type": "search", "query": "tiger ranking"},
+                "results": [
+                    {"url": "https://a.example/1", "title": "Tigers", "snippet": "Big cats"},
+                    {"url": "https://b.example/2", "title": "Lion stats"},
+                ],
+            },
+        },
+        {"type": "response.completed", "response": {}},
+    ]
+    lines = _drive_stream(sse_events, ["web_search"], monkeypatch)
+    events = _tool_events(lines)
+    ends = [e for e in events if e["type"] == "tool_end"]
+    body = ends[0]["result"]
+    assert "Title: Tigers" in body
+    assert "URL: https://a.example/1" in body
+    assert "Snippet: Big cats" in body
+    assert "Title: Lion stats" in body
+
+
+def test_web_search_action_sources_url_only_falls_back(monkeypatch):
+    """`action.sources` URLs are surfaced when `results` is absent."""
+    sse_events = [
+        {
+            "type": "response.output_item.done",
+            "item": {
+                "type": "web_search_call",
+                "id": "ws_s",
+                "action": {
+                    "type": "search",
+                    "query": "X",
+                    "sources": [
+                        {"type": "url", "url": "https://x.example/1"},
+                        "https://x.example/2",
+                    ],
+                },
+            },
+        },
+        {"type": "response.completed", "response": {}},
+    ]
+    lines = _drive_stream(sse_events, ["web_search"], monkeypatch)
+    events = _tool_events(lines)
+    ends = [e for e in events if e["type"] == "tool_end"]
+    body = ends[0]["result"]
+    assert "https://x.example/1" in body
+    assert "https://x.example/2" in body
+
+
 # ── shell_call output fallbacks ────────────────────────────────────────
 
 
diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx
index 9c15b7fddd..1951922b74 100644
--- a/studio/frontend/src/components/assistant-ui/thread.tsx
+++ b/studio/frontend/src/components/assistant-ui/thread.tsx
@@ -230,9 +230,14 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({
         )}
         aria-label="Generated image preview"
       >
-        
-
-
+
+
+ {overlay.title} +
-
- {overlay.title} -
{runningLabel}
- ) : resultText ? ( -
-
- -
-
-              {displayedResult}
-            
+ ) : ( +
+ {showFullCommand ? ( +
+
+ Command + +
+
+                  {command}
+                
+
+ ) : null} + {resultText ? ( +
+
+ Output + +
+
+                  {displayedResult}
+                
+
+ ) : emptyOutput ? ( +

+ Command completed with no output. +

+ ) : null}
- ) : null} + )} ); diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx index f23cb8dc18..c65db443d5 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx @@ -154,14 +154,15 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({ ))}
- ) : result ? ( -
-
-              {typeof result === "string"
-                ? result
-                : JSON.stringify(result, null, 2)}
-            
-
+ ) : url ? ( + + {url} + ) : null} From 1b116d2ecacb25f5085a1b1bcafbd4c445a0cb64 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 12:54:15 +0000 Subject: [PATCH 4/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../core/inference/external_provider.py | 27 ++++++++++++------- .../test_openai_tool_result_fallbacks.py | 6 ++++- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index 2e0c5e6256..64e142d5f0 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -276,9 +276,7 @@ def _extract_web_search_action(item: dict[str, Any]) -> dict[str, Any]: return out -def _format_web_search_per_call_sources( - results: Any, sources: Any -) -> str: +def _format_web_search_per_call_sources(results: Any, sources: Any) -> str: """Format per-call sources as the Title/URL/Snippet block the frontend's parseSearchResults expects. Prefers `results` (snippet bearing, reasoning models) then `action.sources` (urls only). @@ -3583,7 +3581,12 @@ class ExternalProviderClient: if isinstance(entry, str) and entry: parts.append(entry) continue - stdout = entry.get("stdout") or entry.get("text") or entry.get("content") or "" + stdout = ( + entry.get("stdout") + or entry.get("text") + or entry.get("content") + or "" + ) stderr = entry.get("stderr") or "" outcome = entry.get("outcome") or {} chunk_parts: list[str] = [] @@ -3603,7 +3606,7 @@ class ExternalProviderClient: # Unknown shape: surface the raw dict so the # user can see what OpenAI actually returned. try: - chunk_parts.append(_json.dumps(entry, indent=2)) + chunk_parts.append(_json.dumps(entry, indent = 2)) except (TypeError, ValueError): pass if chunk_parts: @@ -3982,16 +3985,22 @@ class ExternalProviderClient: # as the Title/URL/Snippet block the frontend # parser already understands. action_obj = ( - item.get("action") if isinstance(item.get("action"), dict) else {} + item.get("action") + if isinstance(item.get("action"), dict) + else {} ) or {} - per_call_sources = _format_web_search_per_call_sources( - item.get("results"), action_obj.get("sources") + per_call_sources = ( + _format_web_search_per_call_sources( + item.get("results"), + action_obj.get("sources"), + ) ) yield _emit_tool_event( { "type": "tool_end", "tool_call_id": item_id, - "result": per_call_sources or _web_search_card_text(args), + "result": per_call_sources + or _web_search_card_text(args), } ) elif item.get("type") == "shell_call": diff --git a/studio/backend/tests/test_openai_tool_result_fallbacks.py b/studio/backend/tests/test_openai_tool_result_fallbacks.py index e9f8f2a308..f80ca22c21 100644 --- a/studio/backend/tests/test_openai_tool_result_fallbacks.py +++ b/studio/backend/tests/test_openai_tool_result_fallbacks.py @@ -284,7 +284,11 @@ def test_web_search_per_call_results_formatted_as_source_blocks(monkeypatch): "id": "ws_r", "action": {"type": "search", "query": "tiger ranking"}, "results": [ - {"url": "https://a.example/1", "title": "Tigers", "snippet": "Big cats"}, + { + "url": "https://a.example/1", + "title": "Tigers", + "snippet": "Big cats", + }, {"url": "https://b.example/2", "title": "Lion stats"}, ], }, From 2c43a0fbfb7d1d65911484afab970d3f36a0dd5b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 26 May 2026 13:05:31 +0000 Subject: [PATCH 5/5] Studio: cap generated-image overlay to 520px x min(70vh, 620px) Previous overlay used `max-h-full max-w-full object-contain`, which fills whatever flex area is available. On large viewports that scales a generated image past its intrinsic resolution and turns it soft. Explicit caps: `h-auto`, `w-auto`, `max-w-[520px]`, `max-h-[min(70vh, 620px)]`. Aspect ratio preserved, only downscales. Wrapped the image in an `inline-block` relative box so the download/close buttons anchor to the image's actual top-right corner instead of the wider flex container. --- .../src/components/assistant-ui/thread.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 1951922b74..2cf43f39ad 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -231,13 +231,17 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({ aria-label="Generated image preview" >
-
- {overlay.title} -
+
+
+ {overlay.title} +
+