feat(xai): surface hosted X Search activity (#5050)

This commit is contained in:
chengyongru
2026-07-23 13:42:09 +08:00
committed by GitHub
parent f3099286ea
commit 9cf2fb19c2
14 changed files with 431 additions and 8 deletions
+147
View File
@@ -6,7 +6,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from agent.runner_helpers import make_run_spec
from nanobot.agent.hook import CompositeHook
from nanobot.agent.hooks import FileEditActivityHook
from nanobot.agent.progress_hook import AgentProgressHook
from nanobot.agent.runner import AgentRunner
from nanobot.agent.tools.filesystem import EditFileTool, WriteFileTool
from nanobot.config.schema import AgentDefaults
@@ -83,6 +85,151 @@ async def test_runner_streams_provider_progress_deltas_by_default():
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_routes_hosted_tool_events_to_structured_progress():
provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_content_delta, on_tool_call_delta, **kwargs):
await on_tool_call_delta({
"call_id": "local-call",
"name": "read_file",
"arguments_delta": "",
})
await on_tool_call_delta({
"kind": "hosted_tool",
"phase": "start",
"call_id": "x-search-1",
"name": "x_search",
"arguments": {"query": "nanobot oauth"},
"result": None,
})
await on_tool_call_delta({
"kind": "hosted_tool",
"phase": "end",
"call_id": "x-search-1",
"name": "x_search",
"arguments": {"query": "nanobot oauth"},
"result": {"name": "x_semantic_search"},
})
await on_content_delta("done")
return LLMResponse(content="done", tool_calls=[], usage={})
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = MagicMock()
tools.get_definitions.return_value = []
progress_events: list[dict] = []
progress_text: list[str] = []
async def progress_cb(content, *, tool_events=None, **kwargs):
progress_text.append(content)
if tool_events:
progress_events.extend(tool_events)
hook = CompositeHook([AgentProgressHook(on_progress=progress_cb)])
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "search X"}],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
hook=hook,
))
assert result.final_content == "done"
assert result.tools_used == []
assert result.tool_events == []
assert progress_events == [
{
"version": 1,
"phase": "start",
"call_id": "x-search-1",
"name": "x_search",
"arguments": {"query": "nanobot oauth"},
"result": None,
"error": None,
"files": [],
"embeds": [],
},
{
"version": 1,
"phase": "end",
"call_id": "x-search-1",
"name": "x_search",
"arguments": {"query": "nanobot oauth"},
"result": {"name": "x_semantic_search"},
"error": None,
"files": [],
"embeds": [],
},
]
assert progress_text == ['search X "nanobot oauth"', "", "done"]
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_fails_pending_hosted_tool_when_model_request_fails():
provider = MagicMock()
provider.supports_progress_deltas = True
async def chat_stream_with_retry(*, on_tool_call_delta, **kwargs):
await on_tool_call_delta({
"kind": "hosted_tool",
"phase": "start",
"call_id": "x-search-failed",
"name": "x_search",
"arguments": {"query": "nanobot oauth"},
"result": None,
})
return LLMResponse(
content="hosted search backend failed",
finish_reason="error",
)
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = MagicMock()
tools.get_definitions.return_value = []
progress_events: list[dict] = []
async def progress_cb(content, *, tool_events=None, **kwargs):
if tool_events:
progress_events.extend(tool_events)
hook = CompositeHook([AgentProgressHook(on_progress=progress_cb)])
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "search X"}],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb,
hook=hook,
))
assert result.stop_reason == "error"
assert [(event["phase"], event["call_id"]) for event in progress_events] == [
("start", "x-search-failed"),
("error", "x-search-failed"),
]
assert progress_events[-1] == {
"version": 1,
"phase": "error",
"call_id": "x-search-failed",
"name": "x_search",
"arguments": {"query": "nanobot oauth"},
"result": None,
"error": "hosted search backend failed",
"files": [],
"embeds": [],
}
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_path):
provider = MagicMock()
+68 -1
View File
@@ -354,6 +354,73 @@ async def test_raw_response_request_streams_text_usage_and_inline_citations(monk
assert captured["json"]["tools"] == [{"type": "x_search"}]
@pytest.mark.asyncio
async def test_raw_response_request_streams_hosted_x_search_lifecycle(monkeypatch) -> None:
original_client = httpx.AsyncClient
events = [
{
"type": "response.custom_tool_call_input.done",
"item_id": "x-search-1",
"input": '{"query":"nanobot oauth"}',
},
{
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"id": "x-search-1",
"name": "x_semantic_search",
"input": '{"query":"nanobot oauth"}',
"output": [{"text": "large hosted result must not enter activity events"}],
},
},
{
"type": "response.completed",
"response": {"status": "completed", "usage": {}},
},
]
content = "".join(f"data: {json.dumps(event)}\n\n" for event in events)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=content, request=request)
def fake_client(**kwargs) -> httpx.AsyncClient:
return original_client(
transport=httpx.MockTransport(handler),
timeout=kwargs["timeout"],
)
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.AsyncClient", fake_client)
tool_events: list[dict[str, Any]] = []
result = await _request_xai(
"https://cli-chat-proxy.grok.com/v1/responses",
_build_headers("secret", "grok-4.5"),
{"model": "grok-4.5", "tools": [{"type": "x_search"}]},
on_tool_call_delta=lambda event: _append(tool_events, event),
)
assert result[0] == ""
assert tool_events == [
{
"kind": "hosted_tool",
"phase": "start",
"call_id": "x-search-1",
"name": "x_search",
"arguments": {"query": "nanobot oauth"},
"result": None,
},
{
"kind": "hosted_tool",
"phase": "end",
"call_id": "x-search-1",
"name": "x_search",
"arguments": {"query": "nanobot oauth"},
"result": {"name": "x_semantic_search"},
},
]
assert "large hosted result" not in json.dumps(tool_events)
def test_model_capabilities_follow_upstream_aliases_and_default_to_disabled() -> None:
capabilities = _parse_xai_model_capabilities(
{
@@ -522,5 +589,5 @@ def test_large_json_error_body_redacts_camel_case_credentials_before_bounding()
assert len(detail) == 1001
async def _append(target: list[str], value: str) -> None:
async def _append(target: list[Any], value: Any) -> None:
target.append(value)