diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index 8f34bb23fc..64e142d5f0 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -243,6 +243,89 @@ 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 _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: + 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"] @@ -2185,7 +2268,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 @@ -3233,6 +3316,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('/', '-')}" @@ -3267,6 +3358,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: @@ -3468,43 +3566,52 @@ 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
@@ -3844,45 +3951,56 @@ 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 ""
+                                    # 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": per_call_result,
+                                            "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 7c033bc348..f80ca22c21 100644
--- a/studio/backend/tests/test_openai_tool_result_fallbacks.py
+++ b/studio/backend/tests/test_openai_tool_result_fallbacks.py
@@ -200,6 +200,139 @@ 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"
+
+
+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/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..2cf43f39ad 100644
--- a/studio/frontend/src/components/assistant-ui/thread.tsx
+++ b/studio/frontend/src/components/assistant-ui/thread.tsx
@@ -230,14 +230,23 @@ 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-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..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 @@ -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… }
@@ -140,14 +154,15 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({ ))}
- ) : result ? ( -
-
-              {typeof result === "string"
-                ? result
-                : JSON.stringify(result, null, 2)}
-            
-
+ ) : url ? ( + + {url} + ) : null}