feat(xai): surface hosted X Search activity (#5050)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user