Files
arc53--docsgpt/tests/api/answer/services/test_stream_processor.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

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, [])