fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
354 lines
12 KiB
Python
354 lines
12 KiB
Python
"""Tests for application/api/answer/services/stream_processor.py.
|
|
|
|
The previous suite was tightly coupled to Mongo (mock_mongo_db fixture,
|
|
bson.ObjectId, bson.DBRef, find_one, etc.) which no longer exist after the
|
|
Mongo -> Postgres cutover. Rewriting these ~18 tests against the new
|
|
repositories (AgentsRepository / PromptsRepository / ConversationsRepository)
|
|
requires meaningful setup that is best done alongside the migration of the
|
|
StreamProcessor internals themselves.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
# A static 24-hex-char string that is a valid ObjectId hex representation.
|
|
_STATIC_OID = "507f1f77bcf86cd799439011"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGetPromptFunction:
|
|
pass
|
|
|
|
@pytest.mark.unit
|
|
class TestStreamProcessorInitialization:
|
|
pass
|
|
|
|
def test_initializes_with_decoded_token(self):
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
conv_id = _STATIC_OID
|
|
request_data = {
|
|
"question": "What is Python?",
|
|
"conversation_id": conv_id,
|
|
}
|
|
decoded_token = {"sub": "user_123", "email": "test@example.com"}
|
|
|
|
processor = StreamProcessor(request_data, decoded_token)
|
|
|
|
assert processor.data == request_data
|
|
assert processor.decoded_token == decoded_token
|
|
assert processor.initial_user_id == "user_123"
|
|
assert processor.conversation_id == request_data["conversation_id"]
|
|
|
|
def test_initializes_without_token(self):
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
request_data = {"question": "Test question"}
|
|
|
|
processor = StreamProcessor(request_data, None)
|
|
|
|
assert processor.decoded_token is None
|
|
assert processor.initial_user_id is None
|
|
assert processor.data == request_data
|
|
|
|
def test_initializes_default_attributes(self):
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
processor = StreamProcessor({"question": "Test"}, {"sub": "user_123"})
|
|
|
|
assert processor.source == {}
|
|
assert processor.all_sources == []
|
|
assert processor.attachments == []
|
|
assert processor.history == []
|
|
assert processor.agent_config == {}
|
|
assert processor.retriever_config == {}
|
|
assert processor.is_shared_usage is False
|
|
assert processor.shared_token is None
|
|
|
|
def test_extracts_conversation_id_from_request(self):
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
conv_id = _STATIC_OID
|
|
request_data = {"question": "Test", "conversation_id": conv_id}
|
|
|
|
processor = StreamProcessor(request_data, {"sub": "user_123"})
|
|
|
|
assert processor.conversation_id == conv_id
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestStreamProcessorHistoryLoading:
|
|
pass
|
|
|
|
def test_uses_request_history_when_no_conversation_id(self):
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
request_data = {
|
|
"question": "What is Python?",
|
|
"history": [{"prompt": "Hello", "response": "Hi there!"}],
|
|
}
|
|
|
|
processor = StreamProcessor(request_data, {"sub": "user_123"})
|
|
|
|
assert processor.conversation_id is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestStreamProcessorAgentConfiguration:
|
|
pass
|
|
|
|
def test_uses_default_config_without_api_key(self):
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
request_data = {"question": "Test"}
|
|
|
|
processor = StreamProcessor(request_data, {"sub": "user_123"})
|
|
processor._configure_agent()
|
|
|
|
assert isinstance(processor.agent_config, dict)
|
|
assert processor.agent_id is None
|
|
|
|
def test_embedded_workflow_without_saved_id(self):
|
|
"""A preview run with no saved workflow id carries no ``workflow_id``."""
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
request_data = {
|
|
"question": "Test",
|
|
"workflow": {"nodes": [], "edges": []},
|
|
}
|
|
processor = StreamProcessor(request_data, {"sub": "user_123"})
|
|
processor._configure_agent()
|
|
|
|
assert processor.agent_config["agent_type"] == "workflow"
|
|
assert processor.agent_config["workflow"] == {"nodes": [], "edges": []}
|
|
assert "workflow_id" not in processor.agent_config
|
|
|
|
def test_embedded_workflow_with_saved_id_persists_run(self):
|
|
"""A saved workflow id alongside the embedded graph is captured so the
|
|
run can persist a ``workflow_runs`` row for artifact listing."""
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
request_data = {
|
|
"question": "Test",
|
|
"workflow": {"nodes": [], "edges": []},
|
|
"workflow_id": "11111111-1111-1111-1111-111111111111",
|
|
}
|
|
processor = StreamProcessor(request_data, {"sub": "user_123"})
|
|
processor._configure_agent()
|
|
|
|
assert processor.agent_config["agent_type"] == "workflow"
|
|
assert (
|
|
processor.agent_config["workflow_id"]
|
|
== "11111111-1111-1111-1111-111111111111"
|
|
)
|
|
assert processor.agent_config["workflow_owner"] == "user_123"
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestStreamProcessorDocPrefetch:
|
|
pass
|
|
|
|
def test_prefetch_skipped_when_no_active_docs(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
processor = StreamProcessor(
|
|
{"question": "Hi there"},
|
|
{"sub": "user_123"},
|
|
)
|
|
processor.initialize()
|
|
processor.create_retriever = MagicMock()
|
|
|
|
docs_together, docs = processor.pre_fetch_docs("Hi there")
|
|
|
|
processor.create_retriever.assert_not_called()
|
|
assert docs_together is None
|
|
assert docs is None
|
|
|
|
def test_prefetch_skipped_when_active_docs_is_default(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
processor = StreamProcessor(
|
|
{"question": "Hi", "active_docs": "default"},
|
|
{"sub": "user_123"},
|
|
)
|
|
processor.initialize()
|
|
processor.create_retriever = MagicMock()
|
|
|
|
docs_together, docs = processor.pre_fetch_docs("Hi")
|
|
|
|
processor.create_retriever.assert_not_called()
|
|
assert docs_together is None
|
|
assert docs is None
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestStreamProcessorAttachments:
|
|
pass
|
|
|
|
def test_handles_empty_attachments(self):
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
request_data = {"question": "Simple question"}
|
|
|
|
processor = StreamProcessor(request_data, {"sub": "user_123"})
|
|
|
|
assert processor.attachments == []
|
|
assert (
|
|
"attachments" not in processor.data
|
|
or processor.data.get("attachments") is None
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestToolPreFetch:
|
|
"""Tests for tool pre-fetching with saved parameter values."""
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestBuildContinuationFromMessages:
|
|
"""Stateless tool continuation rebuilt from the resent messages array.
|
|
|
|
OpenAI-compatible clients (opencode, etc.) resend the full conversation but
|
|
carry no conversation_id, so the agent + pending tool calls are reconstructed
|
|
directly from the request instead of from server-side ``pending_tool_state``.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _make_processor():
|
|
from unittest.mock import MagicMock
|
|
|
|
from application.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
processor = StreamProcessor({"question": ""}, {"sub": "user_123"})
|
|
fake_agent = MagicMock()
|
|
fake_agent.tool_executor.get_tools.return_value = {"search": object()}
|
|
# build_agent touches the DB / config; stub it for this unit test.
|
|
processor.build_agent = MagicMock(return_value=fake_agent)
|
|
return processor, fake_agent
|
|
|
|
def _messages_with_tool_call(self, arguments):
|
|
return [
|
|
{"role": "system", "content": "You are a bot"},
|
|
{"role": "user", "content": "search the docs"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {"name": "search", "arguments": arguments},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call_1", "content": "found it"},
|
|
]
|
|
|
|
def test_rebuilds_pending_tool_calls_and_prior_messages(self):
|
|
processor, fake_agent = self._make_processor()
|
|
messages = self._messages_with_tool_call('{"q": "docs"}')
|
|
tool_actions = [{"call_id": "call_1", "result": "found it"}]
|
|
|
|
(
|
|
agent,
|
|
prior_messages,
|
|
tools_dict,
|
|
pending_tool_calls,
|
|
returned_actions,
|
|
reasoning,
|
|
) = processor.build_continuation_from_messages(messages, tool_actions)
|
|
|
|
assert agent is fake_agent
|
|
assert tools_dict == fake_agent.tool_executor.get_tools.return_value
|
|
assert returned_actions is tool_actions
|
|
assert reasoning == ""
|
|
# prior_messages stop before the assistant-with-tool_calls message.
|
|
assert prior_messages == messages[:2]
|
|
assert len(pending_tool_calls) == 1
|
|
call = pending_tool_calls[0]
|
|
assert call["call_id"] == "call_1"
|
|
assert call["name"] == "search"
|
|
assert call["tool_name"] == "search"
|
|
assert call["action_name"] == "search"
|
|
assert call["arguments"] == {"q": "docs"}
|
|
processor.build_agent.assert_called_once_with("")
|
|
|
|
def test_invalid_json_arguments_default_to_empty_dict(self):
|
|
processor, _ = self._make_processor()
|
|
messages = self._messages_with_tool_call("not-json")
|
|
|
|
_, _, _, pending_tool_calls, _, _ = (
|
|
processor.build_continuation_from_messages(messages, [])
|
|
)
|
|
|
|
assert pending_tool_calls[0]["arguments"] == {}
|
|
|
|
def test_dict_arguments_passed_through(self):
|
|
processor, _ = self._make_processor()
|
|
messages = self._messages_with_tool_call({"already": "parsed"})
|
|
|
|
_, _, _, pending_tool_calls, _, _ = (
|
|
processor.build_continuation_from_messages(messages, [])
|
|
)
|
|
|
|
assert pending_tool_calls[0]["arguments"] == {"already": "parsed"}
|
|
|
|
def test_uses_last_assistant_tool_call_message(self):
|
|
processor, _ = self._make_processor()
|
|
messages = [
|
|
{"role": "user", "content": "first"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "old",
|
|
"type": "function",
|
|
"function": {"name": "search", "arguments": "{}"},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "old", "content": "r1"},
|
|
{"role": "user", "content": "second"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "new",
|
|
"type": "function",
|
|
"function": {"name": "search", "arguments": "{}"},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "new", "content": "r2"},
|
|
]
|
|
|
|
_, prior_messages, _, pending_tool_calls, _, _ = (
|
|
processor.build_continuation_from_messages(messages, [])
|
|
)
|
|
|
|
assert pending_tool_calls[0]["call_id"] == "new"
|
|
# Everything up to (not including) the last assistant tool_calls message.
|
|
assert prior_messages == messages[:4]
|
|
|
|
def test_raises_when_no_assistant_tool_calls(self):
|
|
processor, _ = self._make_processor()
|
|
messages = [
|
|
{"role": "user", "content": "hi"},
|
|
{"role": "assistant", "content": "no tools here"},
|
|
]
|
|
|
|
with pytest.raises(ValueError, match="No assistant message with tool_calls"):
|
|
processor.build_continuation_from_messages(messages, [])
|
|
|
|
|