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:
parent
0894a1fad8
commit
9807cdf039
5 changed files with 191 additions and 46 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue