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 <pre> 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.
This commit is contained in:
Daniel Han 2026-05-26 12:53:39 +00:00
commit 9807cdf039
5 changed files with 191 additions and 46 deletions

View file

@ -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 <pre>. 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 <pre>. 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":

View file

@ -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 ────────────────────────────────────────

View file

@ -230,9 +230,14 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({
)}
aria-label="Generated image preview"
>
<div className="pointer-events-auto relative flex min-h-0 w-full max-w-[1100px] flex-1 flex-col items-center justify-center gap-3 rounded-3xl bg-muted/10 p-3 ring-1 ring-border/20">
<div className="absolute inset-x-3 top-3 z-10 flex justify-end">
<div className="flex shrink-0 items-center gap-2 rounded-full bg-background/80 p-1.5 ring-1 ring-border/30 backdrop-blur-sm">
<div className="pointer-events-auto relative flex min-h-0 w-full max-w-[1100px] flex-1 flex-col items-center justify-center gap-3">
<div className="relative flex min-h-0 flex-1 items-center justify-center">
<img
src={overlay.image}
alt={overlay.title}
className="max-h-full max-w-full rounded-2xl object-contain"
/>
<div className="absolute right-2 top-2 z-10 flex shrink-0 items-center gap-2 rounded-full bg-background/80 p-1.5 ring-1 ring-border/30 backdrop-blur-sm">
<Button
type="button"
variant="ghost"
@ -260,13 +265,6 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 items-center justify-center pt-1">
<img
src={overlay.image}
alt={overlay.title}
className="max-h-full max-w-full object-contain"
/>
</div>
<div
className="w-full max-w-[min(100%,46rem)] shrink-0 text-center"
title={overlay.title}

View file

@ -175,6 +175,12 @@ const CodeExecutionToolUIImpl: ToolCallMessagePartComponent = ({
[resultText],
);
// Surface the full untruncated command in the expanded body so a
// user clicking the row can read the heredoc that was clipped in
// the trigger label.
const showFullCommand = !isRunning && kind === "bash" && !!command && command !== commandLabel;
const emptyOutput = !isRunning && !resultText;
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger
@ -188,16 +194,36 @@ const CodeExecutionToolUIImpl: ToolCallMessagePartComponent = ({
<LoaderIcon className="size-3.5 animate-spin" />
<span>{runningLabel}</span>
</div>
) : resultText ? (
<div>
<div className="flex justify-end">
<CopyBtn text={resultText} />
</div>
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{displayedResult}
</pre>
) : (
<div className="flex flex-col gap-2">
{showFullCommand ? (
<div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">Command</span>
<CopyBtn text={command} />
</div>
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{command}
</pre>
</div>
) : null}
{resultText ? (
<div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">Output</span>
<CopyBtn text={resultText} />
</div>
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{displayedResult}
</pre>
</div>
) : emptyOutput ? (
<p className="text-xs italic text-muted-foreground">
Command completed with no output.
</p>
) : null}
</div>
) : null}
)}
</ToolFallbackContent>
</ToolFallbackRoot>
);

View file

@ -154,14 +154,15 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
</Source>
))}
</div>
) : result ? (
<div>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
</div>
) : url ? (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="break-all text-xs text-primary underline-offset-4 hover:underline"
>
{url}
</a>
) : null}
</ToolFallbackContent>
</ToolFallbackRoot>