mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 14:07:55 +00:00
Rebased and cleaned-up version of PR #113 by @mricharz, resolved against current main (including Codex engine, Gemini thought_signature, and agent manager fixes merged since the original PR). MCP Transport & Client: - StreamableHTTPTransport with session tracking, SSE parsing, timeouts - MCPClient.initialize() sends proper MCP handshake (protocolVersion, capabilities, clientInfo) + notifications/initialized - Fix StdioTransport constructor: command=[command] + args - MCPRequest.to_dict() with notification support (id=None) External MCP Discovery: - _discover_external_mcp supports both url (HTTP) and command (stdio) - Per-server include_tools / exclude_tools filtering - MCP clients persisted on JarvisSystem for runtime lifetime Streaming Tool-Call Support (stream_full): - StreamChunk dataclass in _stubs.py (content, tool_calls, finish_reason, usage) - Default stream_full() on InferenceEngine ABC wraps stream() for backward compat - _OpenAICompatibleEngine.stream_full() with SSE parsing - CloudEngine: _stream_full_openai (OpenAI/OpenRouter/MiniMax/Codex routing) _stream_full_anthropic (event-based → OpenAI delta format) - InstrumentedEngine, MultiEngine: stream_full delegation - GuardrailsEngine: stream_full with post-hoc security scanning (FIXED: original PR bypassed output scanning — now accumulates and scans like stream()) Other improvements: - _prepare_anthropic_messages() extracted to eliminate duplication - Default tool_choice=auto when tools are provided (OpenAI compat engines) - MCP tool injection into managed agent streaming path - Documentation: docs/user-guide/mcp-external-servers.md Tests: ~59 new tests across 8 test files, all passing. Closes PR #113 Co-Authored-By: mricharz <mricharz@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
467 lines
15 KiB
Python
467 lines
15 KiB
Python
"""Tests for CloudEngine.stream_full, _stream_full_openai, _stream_full_anthropic,
|
|
and _prepare_anthropic_messages."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, List
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from openjarvis.core.types import Message, Role, ToolCall
|
|
from openjarvis.engine._stubs import StreamChunk
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_cloud_engine(**overrides: Any) -> Any:
|
|
"""Create a CloudEngine without calling __init__ (no env vars needed)."""
|
|
from openjarvis.engine.cloud import CloudEngine
|
|
|
|
engine = CloudEngine.__new__(CloudEngine)
|
|
engine._openai_client = overrides.get("openai_client")
|
|
engine._anthropic_client = overrides.get("anthropic_client")
|
|
engine._google_client = overrides.get("google_client")
|
|
engine._openrouter_client = overrides.get("openrouter_client")
|
|
engine._minimax_client = overrides.get("minimax_client")
|
|
return engine
|
|
|
|
|
|
def _openai_chunk(
|
|
*,
|
|
content: str | None = None,
|
|
tool_calls: list | None = None,
|
|
finish_reason: str | None = None,
|
|
) -> MagicMock:
|
|
"""Build a mock OpenAI streaming chunk."""
|
|
delta = MagicMock()
|
|
delta.content = content
|
|
delta.tool_calls = tool_calls
|
|
choice = MagicMock()
|
|
choice.delta = delta
|
|
choice.finish_reason = finish_reason
|
|
chunk = MagicMock()
|
|
chunk.choices = [choice]
|
|
return chunk
|
|
|
|
|
|
def _openai_tool_call_delta(
|
|
*,
|
|
index: int = 0,
|
|
tc_id: str = "",
|
|
name: str = "",
|
|
arguments: str = "",
|
|
) -> MagicMock:
|
|
tc = MagicMock()
|
|
tc.index = index
|
|
tc.id = tc_id
|
|
tc.function = MagicMock()
|
|
tc.function.name = name
|
|
tc.function.arguments = arguments
|
|
return tc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _stream_full_openai tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_full_openai_content():
|
|
"""Mock OpenAI streaming response with content chunks."""
|
|
mock_client = MagicMock()
|
|
chunks = [
|
|
_openai_chunk(content="Hello"),
|
|
_openai_chunk(content=" world"),
|
|
_openai_chunk(finish_reason="stop"),
|
|
]
|
|
mock_client.chat.completions.create.return_value = iter(chunks)
|
|
|
|
engine = _make_cloud_engine(openai_client=mock_client)
|
|
msgs = [Message(role=Role.USER, content="hi")]
|
|
|
|
result: List[StreamChunk] = []
|
|
async for sc in engine._stream_full_openai(
|
|
msgs,
|
|
model="gpt-4o",
|
|
temperature=0.7,
|
|
max_tokens=100,
|
|
):
|
|
result.append(sc)
|
|
|
|
assert len(result) == 3
|
|
assert result[0].content == "Hello"
|
|
assert result[1].content == " world"
|
|
assert result[2].finish_reason == "stop"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_full_openai_tool_calls():
|
|
"""Mock response with tool_call deltas, verify StreamChunk.tool_calls format."""
|
|
mock_client = MagicMock()
|
|
tc1 = _openai_tool_call_delta(index=0, tc_id="call_1", name="calc", arguments="")
|
|
tc2 = _openai_tool_call_delta(index=0, tc_id="", name="", arguments='{"x": 1}')
|
|
chunks = [
|
|
_openai_chunk(tool_calls=[tc1]),
|
|
_openai_chunk(tool_calls=[tc2]),
|
|
_openai_chunk(finish_reason="tool_calls"),
|
|
]
|
|
mock_client.chat.completions.create.return_value = iter(chunks)
|
|
|
|
engine = _make_cloud_engine(openai_client=mock_client)
|
|
msgs = [Message(role=Role.USER, content="calc")]
|
|
|
|
result: List[StreamChunk] = []
|
|
async for sc in engine._stream_full_openai(
|
|
msgs,
|
|
model="gpt-4o",
|
|
temperature=0.7,
|
|
max_tokens=100,
|
|
):
|
|
result.append(sc)
|
|
|
|
assert result[0].tool_calls is not None
|
|
assert result[0].tool_calls[0]["function"]["name"] == "calc"
|
|
assert result[0].tool_calls[0]["id"] == "call_1"
|
|
assert result[1].tool_calls[0]["function"]["arguments"] == '{"x": 1}'
|
|
assert result[2].finish_reason == "tool_calls"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_full_openai_finish_reason():
|
|
"""Verify finish_reason='tool_calls' and 'stop' propagated correctly."""
|
|
mock_client = MagicMock()
|
|
chunks_stop = [
|
|
_openai_chunk(content="ok"),
|
|
_openai_chunk(finish_reason="stop"),
|
|
]
|
|
mock_client.chat.completions.create.return_value = iter(chunks_stop)
|
|
|
|
engine = _make_cloud_engine(openai_client=mock_client)
|
|
msgs = [Message(role=Role.USER, content="hi")]
|
|
|
|
result = []
|
|
async for sc in engine._stream_full_openai(
|
|
msgs,
|
|
model="gpt-4o",
|
|
temperature=0.7,
|
|
max_tokens=100,
|
|
):
|
|
result.append(sc)
|
|
|
|
assert result[-1].finish_reason == "stop"
|
|
|
|
# Now test tool_calls finish
|
|
tc = _openai_tool_call_delta(index=0, tc_id="c1", name="fn", arguments="{}")
|
|
chunks_tc = [
|
|
_openai_chunk(tool_calls=[tc]),
|
|
_openai_chunk(finish_reason="tool_calls"),
|
|
]
|
|
mock_client.chat.completions.create.return_value = iter(chunks_tc)
|
|
|
|
result2 = []
|
|
async for sc in engine._stream_full_openai(
|
|
msgs,
|
|
model="gpt-4o",
|
|
temperature=0.7,
|
|
max_tokens=100,
|
|
):
|
|
result2.append(sc)
|
|
|
|
assert result2[-1].finish_reason == "tool_calls"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _stream_full_anthropic tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _anthropic_event(event_type: str, **kwargs: Any) -> MagicMock:
|
|
"""Build a mock Anthropic stream event."""
|
|
event = MagicMock()
|
|
event.type = event_type
|
|
for k, v in kwargs.items():
|
|
setattr(event, k, v)
|
|
return event
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_full_anthropic_content():
|
|
"""Mock Anthropic stream events with text content."""
|
|
# Build content_block_start with text type
|
|
text_block = MagicMock()
|
|
text_block.type = "text"
|
|
|
|
# Build text delta
|
|
text_delta = MagicMock()
|
|
text_delta.type = "text_delta"
|
|
text_delta.text = "Hello world"
|
|
|
|
# Build message_delta with stop
|
|
msg_delta = MagicMock()
|
|
msg_delta.stop_reason = "end_turn"
|
|
|
|
events = [
|
|
_anthropic_event("content_block_start", content_block=text_block),
|
|
_anthropic_event("content_block_delta", delta=text_delta),
|
|
_anthropic_event("message_delta", delta=msg_delta),
|
|
]
|
|
|
|
mock_stream = MagicMock()
|
|
mock_stream.__enter__ = MagicMock(return_value=iter(events))
|
|
mock_stream.__exit__ = MagicMock(return_value=False)
|
|
|
|
mock_anthropic = MagicMock()
|
|
mock_anthropic.messages.stream.return_value = mock_stream
|
|
|
|
engine = _make_cloud_engine(anthropic_client=mock_anthropic)
|
|
msgs = [Message(role=Role.USER, content="hi")]
|
|
|
|
result: List[StreamChunk] = []
|
|
async for sc in engine._stream_full_anthropic(
|
|
msgs,
|
|
model="claude-sonnet-4-20250514",
|
|
temperature=0.7,
|
|
max_tokens=100,
|
|
):
|
|
result.append(sc)
|
|
|
|
# Should have text content and a finish reason
|
|
content_chunks = [r for r in result if r.content is not None]
|
|
assert len(content_chunks) >= 1
|
|
assert content_chunks[0].content == "Hello world"
|
|
|
|
finish_chunks = [r for r in result if r.finish_reason is not None]
|
|
assert len(finish_chunks) == 1
|
|
assert finish_chunks[0].finish_reason == "stop"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_full_anthropic_tool_calls():
|
|
"""Mock Anthropic tool_use events, verify OpenAI-delta-format tool_calls."""
|
|
# content_block_start with tool_use
|
|
tool_block = MagicMock()
|
|
tool_block.type = "tool_use"
|
|
tool_block.id = "toolu_123"
|
|
tool_block.name = "get_weather"
|
|
|
|
# input_json_delta
|
|
json_delta = MagicMock()
|
|
json_delta.type = "input_json_delta"
|
|
json_delta.partial_json = '{"city": "Berlin"}'
|
|
|
|
# message_delta with tool_use stop
|
|
msg_delta = MagicMock()
|
|
msg_delta.stop_reason = "tool_use"
|
|
|
|
events = [
|
|
_anthropic_event("content_block_start", content_block=tool_block),
|
|
_anthropic_event("content_block_delta", delta=json_delta),
|
|
_anthropic_event("message_delta", delta=msg_delta),
|
|
]
|
|
|
|
mock_stream = MagicMock()
|
|
mock_stream.__enter__ = MagicMock(return_value=iter(events))
|
|
mock_stream.__exit__ = MagicMock(return_value=False)
|
|
|
|
mock_anthropic = MagicMock()
|
|
mock_anthropic.messages.stream.return_value = mock_stream
|
|
|
|
engine = _make_cloud_engine(anthropic_client=mock_anthropic)
|
|
msgs = [Message(role=Role.USER, content="weather?")]
|
|
|
|
result: List[StreamChunk] = []
|
|
async for sc in engine._stream_full_anthropic(
|
|
msgs,
|
|
model="claude-sonnet-4-20250514",
|
|
temperature=0.7,
|
|
max_tokens=100,
|
|
):
|
|
result.append(sc)
|
|
|
|
# First chunk: tool_use start with name
|
|
assert result[0].tool_calls is not None
|
|
assert result[0].tool_calls[0]["function"]["name"] == "get_weather"
|
|
assert result[0].tool_calls[0]["id"] == "toolu_123"
|
|
|
|
# Second chunk: arguments fragment
|
|
assert result[1].tool_calls is not None
|
|
assert result[1].tool_calls[0]["function"]["arguments"] == '{"city": "Berlin"}'
|
|
|
|
# Third chunk: finish with tool_calls
|
|
assert result[2].finish_reason == "tool_calls"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_full_anthropic_finish_reason():
|
|
"""message_delta with stop_reason='tool_use' maps to finish_reason='tool_calls'."""
|
|
msg_delta_tool = MagicMock()
|
|
msg_delta_tool.stop_reason = "tool_use"
|
|
|
|
msg_delta_stop = MagicMock()
|
|
msg_delta_stop.stop_reason = "end_turn"
|
|
|
|
# Test tool_use -> tool_calls
|
|
events_tool = [_anthropic_event("message_delta", delta=msg_delta_tool)]
|
|
mock_stream = MagicMock()
|
|
mock_stream.__enter__ = MagicMock(return_value=iter(events_tool))
|
|
mock_stream.__exit__ = MagicMock(return_value=False)
|
|
|
|
mock_anthropic = MagicMock()
|
|
mock_anthropic.messages.stream.return_value = mock_stream
|
|
|
|
engine = _make_cloud_engine(anthropic_client=mock_anthropic)
|
|
msgs = [Message(role=Role.USER, content="test")]
|
|
|
|
result = []
|
|
async for sc in engine._stream_full_anthropic(
|
|
msgs,
|
|
model="claude-sonnet-4-20250514",
|
|
temperature=0.7,
|
|
max_tokens=100,
|
|
):
|
|
result.append(sc)
|
|
assert result[0].finish_reason == "tool_calls"
|
|
|
|
# Test end_turn -> stop
|
|
events_stop = [_anthropic_event("message_delta", delta=msg_delta_stop)]
|
|
mock_stream2 = MagicMock()
|
|
mock_stream2.__enter__ = MagicMock(return_value=iter(events_stop))
|
|
mock_stream2.__exit__ = MagicMock(return_value=False)
|
|
mock_anthropic.messages.stream.return_value = mock_stream2
|
|
|
|
result2 = []
|
|
async for sc in engine._stream_full_anthropic(
|
|
msgs,
|
|
model="claude-sonnet-4-20250514",
|
|
temperature=0.7,
|
|
max_tokens=100,
|
|
):
|
|
result2.append(sc)
|
|
assert result2[0].finish_reason == "stop"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _prepare_anthropic_messages tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_prepare_anthropic_messages_system():
|
|
"""System message extracted separately from chat messages."""
|
|
engine = _make_cloud_engine()
|
|
msgs = [
|
|
Message(role=Role.SYSTEM, content="You are helpful"),
|
|
Message(role=Role.USER, content="Hello"),
|
|
]
|
|
|
|
system_text, chat_msgs = engine._prepare_anthropic_messages(msgs)
|
|
assert system_text == "You are helpful"
|
|
assert len(chat_msgs) == 1
|
|
assert chat_msgs[0]["role"] == "user"
|
|
assert chat_msgs[0]["content"] == "Hello"
|
|
|
|
|
|
def test_prepare_anthropic_messages_tool_result():
|
|
"""Tool role converted to user + tool_result content block."""
|
|
engine = _make_cloud_engine()
|
|
msgs = [
|
|
Message(role=Role.USER, content="What's the weather?"),
|
|
Message(
|
|
role=Role.TOOL,
|
|
content='{"temp": 20}',
|
|
tool_call_id="call_abc",
|
|
),
|
|
]
|
|
|
|
system_text, chat_msgs = engine._prepare_anthropic_messages(msgs)
|
|
assert system_text == ""
|
|
assert len(chat_msgs) == 2
|
|
# Second message is the tool result wrapped as user
|
|
tool_msg = chat_msgs[1]
|
|
assert tool_msg["role"] == "user"
|
|
assert isinstance(tool_msg["content"], list)
|
|
assert tool_msg["content"][0]["type"] == "tool_result"
|
|
assert tool_msg["content"][0]["tool_use_id"] == "call_abc"
|
|
assert tool_msg["content"][0]["content"] == '{"temp": 20}'
|
|
|
|
|
|
def test_prepare_anthropic_messages_tool_calls():
|
|
"""Assistant with tool_calls converted to content blocks with tool_use."""
|
|
engine = _make_cloud_engine()
|
|
msgs = [
|
|
Message(
|
|
role=Role.ASSISTANT,
|
|
content="Let me check.",
|
|
tool_calls=[
|
|
ToolCall(
|
|
id="call_1", name="get_weather", arguments='{"city": "Berlin"}'
|
|
),
|
|
],
|
|
),
|
|
]
|
|
|
|
system_text, chat_msgs = engine._prepare_anthropic_messages(msgs)
|
|
assert len(chat_msgs) == 1
|
|
blocks = chat_msgs[0]["content"]
|
|
assert blocks[0]["type"] == "text"
|
|
assert blocks[0]["text"] == "Let me check."
|
|
assert blocks[1]["type"] == "tool_use"
|
|
assert blocks[1]["id"] == "call_1"
|
|
assert blocks[1]["name"] == "get_weather"
|
|
assert blocks[1]["input"] == {"city": "Berlin"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# stream_full routing tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_full_routes_to_anthropic():
|
|
"""model='claude-xxx' routes to _stream_full_anthropic."""
|
|
msg_delta = MagicMock()
|
|
msg_delta.stop_reason = "end_turn"
|
|
events = [_anthropic_event("message_delta", delta=msg_delta)]
|
|
mock_stream = MagicMock()
|
|
mock_stream.__enter__ = MagicMock(return_value=iter(events))
|
|
mock_stream.__exit__ = MagicMock(return_value=False)
|
|
|
|
mock_anthropic = MagicMock()
|
|
mock_anthropic.messages.stream.return_value = mock_stream
|
|
|
|
engine = _make_cloud_engine(anthropic_client=mock_anthropic)
|
|
msgs = [Message(role=Role.USER, content="test")]
|
|
|
|
result = []
|
|
async for sc in engine.stream_full(msgs, model="claude-sonnet-4-20250514"):
|
|
result.append(sc)
|
|
|
|
# Verify Anthropic client was used
|
|
mock_anthropic.messages.stream.assert_called_once()
|
|
assert any(r.finish_reason is not None for r in result)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_full_routes_to_openai():
|
|
"""model='gpt-xxx' routes to _stream_full_openai."""
|
|
mock_client = MagicMock()
|
|
chunks = [
|
|
_openai_chunk(content="hi"),
|
|
_openai_chunk(finish_reason="stop"),
|
|
]
|
|
mock_client.chat.completions.create.return_value = iter(chunks)
|
|
|
|
engine = _make_cloud_engine(openai_client=mock_client)
|
|
msgs = [Message(role=Role.USER, content="test")]
|
|
|
|
result = []
|
|
async for sc in engine.stream_full(msgs, model="gpt-4o"):
|
|
result.append(sc)
|
|
|
|
# Verify OpenAI client was used
|
|
mock_client.chat.completions.create.assert_called_once()
|
|
assert result[0].content == "hi"
|
|
assert result[1].finish_reason == "stop"
|