chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
@@ -0,0 +1,304 @@
"""Tests for application/api/answer/services/compression/message_builder.py"""
import pytest
from application.api.answer.services.compression.message_builder import MessageBuilder
@pytest.mark.unit
class TestBuildFromCompressedContext:
def test_no_compression_returns_system_only(self):
messages = MessageBuilder.build_from_compressed_context(
system_prompt="You are helpful.",
compressed_summary=None,
recent_queries=[],
)
assert len(messages) == 1
assert messages[0]["role"] == "system"
assert messages[0]["content"] == "You are helpful."
def test_with_recent_queries_no_compression(self):
queries = [
{"prompt": "Hello", "response": "Hi there!"},
{"prompt": "How are you?", "response": "I'm fine."},
]
messages = MessageBuilder.build_from_compressed_context(
system_prompt="System prompt",
compressed_summary=None,
recent_queries=queries,
)
# system + 2 * (user + assistant) = 5
assert len(messages) == 5
assert messages[1] == {"role": "user", "content": "Hello"}
assert messages[2] == {"role": "assistant", "content": "Hi there!"}
assert messages[3] == {"role": "user", "content": "How are you?"}
assert messages[4] == {"role": "assistant", "content": "I'm fine."}
def test_with_compressed_summary_appended_to_system(self):
messages = MessageBuilder.build_from_compressed_context(
system_prompt="You are helpful.",
compressed_summary="Previous: user asked about Python.",
recent_queries=[{"prompt": "More?", "response": "Sure."}],
)
system_content = messages[0]["content"]
assert "This session is being continued" in system_content
assert "Previous: user asked about Python." in system_content
def test_mid_execution_context_type(self):
messages = MessageBuilder.build_from_compressed_context(
system_prompt="System",
compressed_summary="Summary here",
recent_queries=[{"prompt": "q", "response": "r"}],
context_type="mid_execution",
)
system_content = messages[0]["content"]
assert "Context window limit reached" in system_content
def test_include_tool_calls(self):
queries = [
{
"prompt": "Search for X",
"response": "Found X",
"tool_calls": [
{
"call_id": "call-1",
"action_name": "search",
"arguments": {"q": "X"},
"result": "X found",
}
],
}
]
messages = MessageBuilder.build_from_compressed_context(
system_prompt="System",
compressed_summary=None,
recent_queries=queries,
include_tool_calls=True,
)
# system + user + assistant + tool_call_assistant + tool_response = 5
assert len(messages) == 5
assert messages[3]["role"] == "assistant"
assert messages[3].get("tool_calls") is not None
assert messages[3]["tool_calls"][0]["function"]["name"] == "search"
assert messages[4]["role"] == "tool"
assert messages[4].get("tool_call_id") == "call-1"
def test_tool_calls_not_included_by_default(self):
queries = [
{
"prompt": "Search",
"response": "Found",
"tool_calls": [
{
"call_id": "c1",
"action_name": "search",
"arguments": {},
"result": "ok",
}
],
}
]
messages = MessageBuilder.build_from_compressed_context(
system_prompt="System",
compressed_summary=None,
recent_queries=queries,
include_tool_calls=False,
)
# system + user + assistant = 3 (no tool messages)
assert len(messages) == 3
def test_tool_call_without_call_id_generates_uuid(self):
queries = [
{
"prompt": "q",
"response": "r",
"tool_calls": [
{
"action_name": "act",
"arguments": {},
"result": "res",
}
],
}
]
messages = MessageBuilder.build_from_compressed_context(
system_prompt="S",
compressed_summary=None,
recent_queries=queries,
include_tool_calls=True,
)
assistant_msg = messages[3]
call_id = assistant_msg["tool_calls"][0]["id"]
assert call_id is not None
assert len(call_id) > 0
def test_continuation_message_when_no_recent_queries_but_has_summary(self):
messages = MessageBuilder.build_from_compressed_context(
system_prompt="System",
compressed_summary="Everything was compressed",
recent_queries=[],
)
# system + continuation user message = 2
assert len(messages) == 2
assert messages[1]["role"] == "user"
assert "continue" in messages[1]["content"].lower()
def test_no_continuation_when_no_summary(self):
messages = MessageBuilder.build_from_compressed_context(
system_prompt="System",
compressed_summary=None,
recent_queries=[],
)
assert len(messages) == 1
def test_queries_without_prompt_or_response_skipped(self):
queries = [
{"other_field": "value"},
{"prompt": "real", "response": "answer"},
]
messages = MessageBuilder.build_from_compressed_context(
system_prompt="S",
compressed_summary=None,
recent_queries=queries,
)
# system + 1 valid query (user + assistant) = 3
assert len(messages) == 3
@pytest.mark.unit
class TestAppendCompressionContext:
def test_pre_request_context(self):
result = MessageBuilder._append_compression_context(
"Original prompt", "Summary text", "pre_request"
)
assert "This session is being continued" in result
assert "Summary text" in result
assert result.startswith("Original prompt")
def test_mid_execution_context(self):
result = MessageBuilder._append_compression_context(
"Original prompt", "Summary text", "mid_execution"
)
assert "Context window limit reached" in result
assert "Summary text" in result
def test_removes_existing_compression_context(self):
prompt_with_existing = (
"Original prompt\n\n---\n\nThis session is being continued from old"
)
result = MessageBuilder._append_compression_context(
prompt_with_existing, "New summary", "pre_request"
)
# Should not contain old context twice
assert result.count("This session is being continued") == 1
assert "New summary" in result
def test_removes_mid_execution_context(self):
prompt_with_existing = (
"Original\n\n---\n\nContext window limit reached during execution. Old."
)
result = MessageBuilder._append_compression_context(
prompt_with_existing, "New", "mid_execution"
)
assert result.count("Context window limit reached") == 1
@pytest.mark.unit
class TestRebuildMessagesAfterCompression:
def test_basic_rebuild(self):
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "old message"},
{"role": "assistant", "content": "old reply"},
]
recent = [{"prompt": "new q", "response": "new r"}]
result = MessageBuilder.rebuild_messages_after_compression(
messages=messages,
compressed_summary="Everything was compressed.",
recent_queries=recent,
)
assert result is not None
# system + user + assistant = 3
assert len(result) == 3
assert "Context window limit reached" in result[0]["content"]
assert result[1] == {"role": "user", "content": "new q"}
assert result[2] == {"role": "assistant", "content": "new r"}
def test_returns_none_without_system_message(self):
messages = [
{"role": "user", "content": "hello"},
]
result = MessageBuilder.rebuild_messages_after_compression(
messages=messages,
compressed_summary="summary",
recent_queries=[],
)
assert result is None
def test_no_summary_keeps_system_unchanged(self):
messages = [{"role": "system", "content": "Be helpful."}]
result = MessageBuilder.rebuild_messages_after_compression(
messages=messages,
compressed_summary=None,
recent_queries=[],
)
assert result is not None
assert result[0]["content"] == "Be helpful."
def test_include_tool_calls_in_rebuild(self):
messages = [{"role": "system", "content": "S"}]
recent = [
{
"prompt": "q",
"response": "r",
"tool_calls": [
{
"call_id": "c1",
"action_name": "act",
"arguments": {"a": 1},
"result": "done",
}
],
}
]
result = MessageBuilder.rebuild_messages_after_compression(
messages=messages,
compressed_summary="s",
recent_queries=recent,
include_tool_calls=True,
)
# system + user + assistant + tool_call + tool_response = 5
assert len(result) == 5
def test_continuation_added_when_no_recent_queries(self):
messages = [{"role": "system", "content": "S"}]
result = MessageBuilder.rebuild_messages_after_compression(
messages=messages,
compressed_summary="All compressed",
recent_queries=[],
)
assert len(result) == 2
assert result[1]["role"] == "user"
assert "continue" in result[1]["content"].lower()
def test_include_current_execution_preserves_extra_messages(self):
messages = [
{"role": "system", "content": "S"},
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "r1"},
{"role": "user", "content": "current execution msg"},
]
recent = [{"prompt": "q1", "response": "r1"}]
result = MessageBuilder.rebuild_messages_after_compression(
messages=messages,
compressed_summary="summary",
recent_queries=recent,
include_current_execution=True,
)
assert result is not None
# Should include the current execution message
contents = [m.get("content") for m in result]
assert "current execution msg" in contents
@@ -0,0 +1,458 @@
"""Tests for application/api/answer/services/compression/orchestrator.py"""
from unittest.mock import MagicMock, patch
import pytest
from application.api.answer.services.compression.orchestrator import (
CompressionOrchestrator,
)
from application.api.answer.services.compression.types import (
CompressionMetadata,
CompressionResult,
)
@pytest.fixture
def mock_conversation_service():
svc = MagicMock()
return svc
@pytest.fixture
def mock_threshold_checker():
checker = MagicMock()
return checker
@pytest.fixture
def orchestrator(mock_conversation_service, mock_threshold_checker):
return CompressionOrchestrator(
conversation_service=mock_conversation_service,
threshold_checker=mock_threshold_checker,
)
@pytest.fixture
def sample_conversation():
return {
"queries": [
{"prompt": "q0", "response": "r0"},
{"prompt": "q1", "response": "r1"},
{"prompt": "q2", "response": "r2"},
],
"compression_metadata": {},
"agent_id": "agent-1",
}
@pytest.fixture
def decoded_token():
return {"sub": "user123"}
@pytest.mark.unit
class TestCompressIfNeeded:
def test_conversation_not_found_returns_failure(
self, orchestrator, mock_conversation_service
):
mock_conversation_service.get_conversation.return_value = None
result = orchestrator.compress_if_needed(
conversation_id="conv1",
user_id="user1",
model_id="gpt-4",
decoded_token={"sub": "user1"},
)
assert result.success is False
assert "not found" in result.error
def test_no_compression_needed(
self,
orchestrator,
mock_conversation_service,
mock_threshold_checker,
sample_conversation,
):
mock_conversation_service.get_conversation.return_value = sample_conversation
mock_threshold_checker.should_compress.return_value = False
result = orchestrator.compress_if_needed(
conversation_id="conv1",
user_id="user1",
model_id="gpt-4",
decoded_token={"sub": "user1"},
)
assert result.success is True
assert result.compression_performed is False
assert len(result.recent_queries) == 3
def test_compression_performed_successfully(
self,
orchestrator,
mock_conversation_service,
mock_threshold_checker,
sample_conversation,
decoded_token,
):
mock_conversation_service.get_conversation.return_value = sample_conversation
mock_threshold_checker.should_compress.return_value = True
mock_metadata = MagicMock(spec=CompressionMetadata)
mock_metadata.compression_ratio = 5.0
mock_metadata.original_token_count = 1000
mock_metadata.compressed_token_count = 200
mock_metadata.to_dict.return_value = {"query_index": 2}
with patch.object(
orchestrator, "_perform_compression"
) as mock_perform:
mock_perform.return_value = CompressionResult.success_with_compression(
"compressed summary",
[{"prompt": "q2", "response": "r2"}],
mock_metadata,
)
result = orchestrator.compress_if_needed(
conversation_id="conv1",
user_id="user1",
model_id="gpt-4",
decoded_token=decoded_token,
)
assert result.success is True
assert result.compression_performed is True
assert result.compressed_summary == "compressed summary"
mock_perform.assert_called_once()
def test_exception_returns_failure(
self,
orchestrator,
mock_conversation_service,
):
mock_conversation_service.get_conversation.side_effect = RuntimeError("DB down")
result = orchestrator.compress_if_needed(
conversation_id="conv1",
user_id="user1",
model_id="gpt-4",
decoded_token={"sub": "user1"},
)
assert result.success is False
assert "DB down" in result.error
def test_custom_query_tokens(
self,
orchestrator,
mock_conversation_service,
mock_threshold_checker,
sample_conversation,
):
mock_conversation_service.get_conversation.return_value = sample_conversation
mock_threshold_checker.should_compress.return_value = False
orchestrator.compress_if_needed(
conversation_id="conv1",
user_id="user1",
model_id="gpt-4",
decoded_token={"sub": "user1"},
current_query_tokens=1000,
)
# user_id flows through so BYOM custom-model UUIDs resolve to
# the user's declared context window in the threshold check.
mock_threshold_checker.should_compress.assert_called_once_with(
sample_conversation, "gpt-4", 1000, user_id="user1"
)
@pytest.mark.unit
class TestPerformCompression:
@patch(
"application.api.answer.services.compression.orchestrator.get_provider_from_model_id"
)
@patch(
"application.api.answer.services.compression.orchestrator.get_api_key_for_provider"
)
@patch("application.api.answer.services.compression.orchestrator.LLMCreator")
@patch("application.api.answer.services.compression.orchestrator.CompressionService")
@patch("application.api.answer.services.compression.orchestrator.settings")
def test_successful_compression(
self,
mock_settings,
MockCompressionService,
MockLLMCreator,
mock_get_api_key,
mock_get_provider,
mock_conversation_service,
mock_threshold_checker,
sample_conversation,
decoded_token,
):
mock_settings.COMPRESSION_MODEL_OVERRIDE = None
mock_get_provider.return_value = "openai"
mock_get_api_key.return_value = "sk-test"
MockLLMCreator.create_llm.return_value = MagicMock()
mock_metadata = MagicMock(spec=CompressionMetadata)
mock_metadata.compression_ratio = 5.0
mock_metadata.original_token_count = 500
mock_metadata.compressed_token_count = 100
mock_svc_instance = MagicMock()
mock_svc_instance.compress_and_save.return_value = mock_metadata
mock_svc_instance.get_compressed_context.return_value = (
"compressed text",
[{"prompt": "q2", "response": "r2"}],
)
MockCompressionService.return_value = mock_svc_instance
# After compression, reload conversation
mock_conversation_service.get_conversation.return_value = sample_conversation
orch = CompressionOrchestrator(
conversation_service=mock_conversation_service,
threshold_checker=mock_threshold_checker,
)
result = orch._perform_compression(
"conv1", sample_conversation, "gpt-4", decoded_token
)
assert result.success is True
assert result.compression_performed is True
assert result.compressed_summary == "compressed text"
mock_svc_instance.compress_and_save.assert_called_once()
@patch(
"application.api.answer.services.compression.orchestrator.get_provider_from_model_id"
)
@patch(
"application.api.answer.services.compression.orchestrator.get_api_key_for_provider"
)
@patch("application.api.answer.services.compression.orchestrator.LLMCreator")
@patch("application.api.answer.services.compression.orchestrator.settings")
def test_uses_compression_model_override(
self,
mock_settings,
MockLLMCreator,
mock_get_api_key,
mock_get_provider,
mock_conversation_service,
mock_threshold_checker,
decoded_token,
):
mock_settings.COMPRESSION_MODEL_OVERRIDE = "gpt-3.5-turbo"
mock_get_provider.return_value = "openai"
mock_get_api_key.return_value = "sk-test"
MockLLMCreator.create_llm.return_value = MagicMock()
conversation = {"queries": [{"prompt": "q", "response": "r"}], "agent_id": "a"}
with patch(
"application.api.answer.services.compression.orchestrator.CompressionService"
) as MockCS:
mock_svc = MagicMock()
mock_svc.compress_and_save.return_value = MagicMock(
compression_ratio=3.0,
original_token_count=300,
compressed_token_count=100,
)
mock_svc.get_compressed_context.return_value = ("s", [])
MockCS.return_value = mock_svc
mock_conversation_service.get_conversation.return_value = conversation
orch = CompressionOrchestrator(
conversation_service=mock_conversation_service,
threshold_checker=mock_threshold_checker,
)
orch._perform_compression("c1", conversation, "gpt-4", decoded_token)
# Verify the override model was used. user_id flows from
# decoded_token['sub'] so per-user BYOM custom-model UUIDs
# resolve.
mock_get_provider.assert_called_with(
"gpt-3.5-turbo", user_id=decoded_token["sub"]
)
@patch(
"application.api.answer.services.compression.orchestrator.get_provider_from_model_id"
)
@patch(
"application.api.answer.services.compression.orchestrator.get_api_key_for_provider"
)
@patch("application.api.answer.services.compression.orchestrator.LLMCreator")
@patch("application.api.answer.services.compression.orchestrator.CompressionService")
@patch("application.api.answer.services.compression.orchestrator.settings")
def test_no_queries_returns_no_compression(
self,
mock_settings,
MockCompressionService,
MockLLMCreator,
mock_get_api_key,
mock_get_provider,
mock_conversation_service,
mock_threshold_checker,
decoded_token,
):
mock_settings.COMPRESSION_MODEL_OVERRIDE = None
mock_get_provider.return_value = "openai"
mock_get_api_key.return_value = "sk-test"
MockLLMCreator.create_llm.return_value = MagicMock()
conversation = {"queries": [], "agent_id": "a"}
orch = CompressionOrchestrator(
conversation_service=mock_conversation_service,
threshold_checker=mock_threshold_checker,
)
result = orch._perform_compression("c1", conversation, "gpt-4", decoded_token)
assert result.success is True
assert result.compression_performed is False
def test_exception_returns_failure(
self,
mock_conversation_service,
mock_threshold_checker,
decoded_token,
):
conversation = {
"queries": [{"prompt": "q", "response": "r"}],
"agent_id": "a",
}
with patch(
"application.api.answer.services.compression.orchestrator.settings"
) as mock_settings, patch(
"application.api.answer.services.compression.orchestrator.get_provider_from_model_id",
side_effect=RuntimeError("provider error"),
):
mock_settings.COMPRESSION_MODEL_OVERRIDE = None
orch = CompressionOrchestrator(
conversation_service=mock_conversation_service,
threshold_checker=mock_threshold_checker,
)
result = orch._perform_compression(
"c1", conversation, "gpt-4", decoded_token
)
assert result.success is False
assert "provider error" in result.error
@pytest.mark.unit
class TestCompressMidExecution:
def test_with_provided_conversation(
self,
orchestrator,
sample_conversation,
decoded_token,
):
with patch.object(
orchestrator, "_perform_compression"
) as mock_perform:
mock_perform.return_value = CompressionResult.success_no_compression([])
orchestrator.compress_mid_execution(
conversation_id="conv1",
user_id="user1",
model_id="gpt-4",
decoded_token=decoded_token,
current_conversation=sample_conversation,
)
mock_perform.assert_called_once_with(
"conv1",
sample_conversation,
"gpt-4",
decoded_token,
user_id="user1",
model_user_id=None,
)
def test_loads_conversation_when_not_provided(
self,
orchestrator,
mock_conversation_service,
sample_conversation,
decoded_token,
):
mock_conversation_service.get_conversation.return_value = sample_conversation
with patch.object(
orchestrator, "_perform_compression"
) as mock_perform:
mock_perform.return_value = CompressionResult.success_no_compression([])
orchestrator.compress_mid_execution(
conversation_id="conv1",
user_id="user1",
model_id="gpt-4",
decoded_token=decoded_token,
)
mock_conversation_service.get_conversation.assert_called_once_with(
"conv1", "user1"
)
mock_perform.assert_called_once()
def test_conversation_not_found_returns_failure(
self,
orchestrator,
mock_conversation_service,
decoded_token,
):
mock_conversation_service.get_conversation.return_value = None
result = orchestrator.compress_mid_execution(
conversation_id="conv1",
user_id="user1",
model_id="gpt-4",
decoded_token=decoded_token,
)
assert result.success is False
assert "not found" in result.error
def test_exception_returns_failure(
self,
orchestrator,
mock_conversation_service,
decoded_token,
):
mock_conversation_service.get_conversation.side_effect = RuntimeError("fail")
result = orchestrator.compress_mid_execution(
conversation_id="conv1",
user_id="user1",
model_id="gpt-4",
decoded_token=decoded_token,
)
assert result.success is False
assert "fail" in result.error
@pytest.mark.unit
class TestOrchestratorInit:
def test_default_threshold_checker(self, mock_conversation_service):
orch = CompressionOrchestrator(
conversation_service=mock_conversation_service
)
assert orch.threshold_checker is not None
assert orch.conversation_service is mock_conversation_service
def test_custom_threshold_checker(
self, mock_conversation_service, mock_threshold_checker
):
orch = CompressionOrchestrator(
conversation_service=mock_conversation_service,
threshold_checker=mock_threshold_checker,
)
assert orch.threshold_checker is mock_threshold_checker
@@ -0,0 +1,423 @@
"""Tests for application/api/answer/services/compression/service.py"""
from unittest.mock import MagicMock, patch
import pytest
from application.api.answer.services.compression.service import CompressionService
from application.api.answer.services.compression.types import CompressionMetadata
@pytest.fixture
def mock_llm():
llm = MagicMock()
llm.gen.return_value = "<summary>Compressed summary content</summary>"
return llm
@pytest.fixture
def mock_conversation_service():
svc = MagicMock()
svc.update_compression_metadata = MagicMock()
return svc
@pytest.fixture
def sample_conversation():
return {
"queries": [
{"prompt": "What is Python?", "response": "A programming language."},
{"prompt": "Tell me more.", "response": "It's versatile and popular."},
{
"prompt": "What about tools?",
"response": "Python has many tools.",
"tool_calls": [
{
"tool_name": "search",
"action_name": "web_search",
"arguments": {"q": "python tools"},
"result": "Found 10 results",
"status": "success",
}
],
},
],
"compression_metadata": {},
}
@pytest.mark.unit
class TestCompressionServiceInit:
@patch("application.api.answer.services.compression.service.settings")
def test_default_prompt_builder(self, mock_settings, mock_llm):
mock_settings.COMPRESSION_PROMPT_VERSION = "v1.0"
with patch(
"application.api.answer.services.compression.service.CompressionPromptBuilder"
):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
assert svc.llm is mock_llm
assert svc.model_id == "gpt-4"
def test_custom_prompt_builder(self, mock_llm):
custom_builder = MagicMock()
svc = CompressionService(
llm=mock_llm, model_id="gpt-4", prompt_builder=custom_builder
)
assert svc.prompt_builder is custom_builder
@pytest.mark.unit
class TestCompressConversation:
def test_successful_compression(self, mock_llm, sample_conversation):
mock_builder = MagicMock()
mock_builder.build_prompt.return_value = [
{"role": "system", "content": "Compress"},
{"role": "user", "content": "Conversation..."},
]
mock_builder.version = "v1.0"
svc = CompressionService(
llm=mock_llm, model_id="gpt-4", prompt_builder=mock_builder
)
with patch(
"application.api.answer.services.compression.service.TokenCounter"
) as MockTC:
MockTC.count_query_tokens.return_value = 1000
MockTC.count_message_tokens.return_value = 100
result = svc.compress_conversation(sample_conversation, 2)
assert isinstance(result, CompressionMetadata)
assert result.query_index == 2
assert result.compressed_summary == "Compressed summary content"
assert result.original_token_count == 1000
assert result.compressed_token_count == 100
assert result.compression_ratio == 10.0
assert result.model_used == "gpt-4"
assert result.compression_prompt_version == "v1.0"
def test_invalid_index_negative(self, mock_llm, sample_conversation):
mock_builder = MagicMock()
mock_builder.version = "v1.0"
svc = CompressionService(
llm=mock_llm, model_id="gpt-4", prompt_builder=mock_builder
)
with pytest.raises(ValueError, match="Invalid compress_up_to_index"):
svc.compress_conversation(sample_conversation, -1)
def test_invalid_index_too_large(self, mock_llm, sample_conversation):
mock_builder = MagicMock()
mock_builder.version = "v1.0"
svc = CompressionService(
llm=mock_llm, model_id="gpt-4", prompt_builder=mock_builder
)
with pytest.raises(ValueError, match="Invalid compress_up_to_index"):
svc.compress_conversation(sample_conversation, 10)
def test_with_existing_compressions(self, mock_llm):
conversation = {
"queries": [
{"prompt": "q1", "response": "r1"},
{"prompt": "q2", "response": "r2"},
],
"compression_metadata": {
"compression_points": [
{
"query_index": 0,
"compressed_summary": "Previous summary",
}
]
},
}
mock_builder = MagicMock()
mock_builder.build_prompt.return_value = [
{"role": "system", "content": "Compress"},
{"role": "user", "content": "..."},
]
mock_builder.version = "v1.0"
svc = CompressionService(
llm=mock_llm, model_id="gpt-4", prompt_builder=mock_builder
)
with patch(
"application.api.answer.services.compression.service.TokenCounter"
) as MockTC:
MockTC.count_query_tokens.return_value = 500
MockTC.count_message_tokens.return_value = 50
result = svc.compress_conversation(conversation, 1)
assert isinstance(result, CompressionMetadata)
# Verify existing compressions were passed to prompt builder
call_args = mock_builder.build_prompt.call_args
assert call_args[0][1] == [
{"query_index": 0, "compressed_summary": "Previous summary"}
]
def test_zero_compressed_tokens_ratio(self, mock_llm, sample_conversation):
mock_builder = MagicMock()
mock_builder.build_prompt.return_value = [
{"role": "system", "content": "C"},
{"role": "user", "content": "..."},
]
mock_builder.version = "v1.0"
svc = CompressionService(
llm=mock_llm, model_id="gpt-4", prompt_builder=mock_builder
)
with patch(
"application.api.answer.services.compression.service.TokenCounter"
) as MockTC:
MockTC.count_query_tokens.return_value = 1000
MockTC.count_message_tokens.return_value = 0
result = svc.compress_conversation(sample_conversation, 2)
assert result.compression_ratio == 0
def test_llm_error_propagates(self, sample_conversation):
llm = MagicMock()
llm.gen.side_effect = RuntimeError("LLM error")
mock_builder = MagicMock()
mock_builder.build_prompt.return_value = [
{"role": "system", "content": "C"},
{"role": "user", "content": "..."},
]
mock_builder.version = "v1.0"
svc = CompressionService(
llm=llm, model_id="gpt-4", prompt_builder=mock_builder
)
with patch(
"application.api.answer.services.compression.service.TokenCounter"
) as MockTC:
MockTC.count_query_tokens.return_value = 100
with pytest.raises(RuntimeError, match="LLM error"):
svc.compress_conversation(sample_conversation, 2)
@pytest.mark.unit
class TestCompressAndSave:
def test_saves_metadata_to_db(
self, mock_llm, mock_conversation_service, sample_conversation
):
mock_builder = MagicMock()
mock_builder.build_prompt.return_value = [
{"role": "system", "content": "C"},
{"role": "user", "content": "..."},
]
mock_builder.version = "v1.0"
svc = CompressionService(
llm=mock_llm,
model_id="gpt-4",
conversation_service=mock_conversation_service,
prompt_builder=mock_builder,
)
with patch(
"application.api.answer.services.compression.service.TokenCounter"
) as MockTC:
MockTC.count_query_tokens.return_value = 500
MockTC.count_message_tokens.return_value = 50
result = svc.compress_and_save("conv_123", sample_conversation, 2)
assert isinstance(result, CompressionMetadata)
mock_conversation_service.update_compression_metadata.assert_called_once_with(
"conv_123", result.to_dict()
)
def test_raises_without_conversation_service(self, mock_llm, sample_conversation):
mock_builder = MagicMock()
mock_builder.version = "v1.0"
svc = CompressionService(
llm=mock_llm, model_id="gpt-4", prompt_builder=mock_builder
)
with pytest.raises(ValueError, match="conversation_service required"):
svc.compress_and_save("conv_123", sample_conversation, 2)
@pytest.mark.unit
class TestGetCompressedContext:
def test_no_compression_returns_full_history(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
conversation = {
"queries": [{"prompt": "q1", "response": "r1"}],
"compression_metadata": {},
}
summary, queries = svc.get_compressed_context(conversation)
assert summary is None
assert queries == [{"prompt": "q1", "response": "r1"}]
def test_no_compression_points_returns_full_history(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
conversation = {
"queries": [{"prompt": "q1", "response": "r1"}],
"compression_metadata": {"is_compressed": True, "compression_points": []},
}
summary, queries = svc.get_compressed_context(conversation)
assert summary is None
assert len(queries) == 1
def test_with_compression_returns_summary_and_recent(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
conversation = {
"queries": [
{"prompt": "q0", "response": "r0"},
{"prompt": "q1", "response": "r1"},
{"prompt": "q2", "response": "r2"},
],
"compression_metadata": {
"is_compressed": True,
"compression_points": [
{
"query_index": 1,
"compressed_summary": "Summary of q0 and q1",
"compressed_token_count": 50,
"original_token_count": 500,
}
],
},
}
summary, queries = svc.get_compressed_context(conversation)
assert summary == "Summary of q0 and q1"
assert len(queries) == 1
assert queries[0]["prompt"] == "q2"
def test_none_queries_returns_empty(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
conversation = {
"queries": None,
"compression_metadata": {},
}
summary, queries = svc.get_compressed_context(conversation)
assert summary is None
assert queries == []
def test_exception_falls_back_to_full_history(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
conversation = {
"queries": [{"prompt": "q", "response": "r"}],
"compression_metadata": {
"is_compressed": True,
"compression_points": "invalid", # This will cause an error
},
}
summary, queries = svc.get_compressed_context(conversation)
assert summary is None
assert queries == [{"prompt": "q", "response": "r"}]
def test_exception_with_none_queries_returns_empty(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
# Force exception by making compression_points non-iterable
conversation = {
"queries": None,
"compression_metadata": {
"is_compressed": True,
"compression_points": "bad",
},
}
summary, queries = svc.get_compressed_context(conversation)
assert summary is None
assert queries == []
@pytest.mark.unit
class TestExtractSummary:
def test_extracts_from_summary_tags(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
response = "<analysis>Some analysis</analysis><summary>The actual summary</summary>"
result = svc._extract_summary(response)
assert result == "The actual summary"
def test_removes_analysis_tags_when_no_summary(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
response = "<analysis>analysis text</analysis>Raw summary text here"
result = svc._extract_summary(response)
assert result == "Raw summary text here"
def test_returns_full_response_when_no_tags(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
response = "Just a plain text response"
result = svc._extract_summary(response)
assert result == "Just a plain text response"
def test_multiline_summary(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
response = "<summary>Line 1\nLine 2\nLine 3</summary>"
result = svc._extract_summary(response)
assert "Line 1" in result
assert "Line 3" in result
def test_strips_whitespace(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
response = "<summary> Trimmed </summary>"
result = svc._extract_summary(response)
assert result == "Trimmed"
@pytest.mark.unit
class TestLogToolCallStats:
def test_no_tool_calls(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
queries = [{"prompt": "q", "response": "r"}]
# Should not raise
svc._log_tool_call_stats(queries)
def test_with_tool_calls(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
queries = [
{
"prompt": "q",
"response": "r",
"tool_calls": [
{
"tool_name": "search",
"action_name": "web",
"result": "result text",
},
{
"tool_name": "search",
"action_name": "web",
"result": "more text",
},
],
}
]
# Should not raise - just logs
svc._log_tool_call_stats(queries)
def test_empty_queries(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
svc._log_tool_call_stats([])
def test_tool_call_with_none_result(self, mock_llm):
svc = CompressionService(llm=mock_llm, model_id="gpt-4")
queries = [
{
"prompt": "q",
"response": "r",
"tool_calls": [
{
"tool_name": "t",
"action_name": "a",
"result": None,
}
],
}
]
svc._log_tool_call_stats(queries)
@@ -0,0 +1,46 @@
from unittest.mock import patch
import pytest
@pytest.mark.unit
class TestCompressionThresholdChecker:
def _make_checker(self, pct=0.7):
from application.api.answer.services.compression.threshold_checker import (
CompressionThresholdChecker,
)
return CompressionThresholdChecker(threshold_percentage=pct)
@patch(
"application.api.answer.services.compression.threshold_checker.get_token_limit",
return_value=8000,
)
@patch(
"application.api.answer.services.compression.threshold_checker.TokenCounter.count_message_tokens",
return_value=6000,
)
def test_check_message_tokens_above_threshold(self, mock_count, mock_limit):
checker = self._make_checker(0.7)
assert checker.check_message_tokens([{"role": "user"}], "gpt-4") is True
@patch(
"application.api.answer.services.compression.threshold_checker.get_token_limit",
return_value=8000,
)
@patch(
"application.api.answer.services.compression.threshold_checker.TokenCounter.count_message_tokens",
return_value=1000,
)
def test_check_message_tokens_below_threshold(self, mock_count, mock_limit):
checker = self._make_checker(0.7)
assert checker.check_message_tokens([{"role": "user"}], "gpt-4") is False
@patch(
"application.api.answer.services.compression.threshold_checker.TokenCounter.count_message_tokens",
side_effect=Exception("Token error"),
)
def test_check_message_tokens_exception_returns_false(self, mock_count):
checker = self._make_checker(0.7)
assert checker.check_message_tokens([], "gpt-4") is False
@@ -0,0 +1,131 @@
"""Tests for application/api/answer/services/compression/types.py"""
from datetime import datetime, timezone
import pytest
from application.api.answer.services.compression.types import (
CompressionMetadata,
CompressionResult,
)
@pytest.mark.unit
class TestCompressionMetadata:
def _make_metadata(self, **overrides):
defaults = dict(
timestamp=datetime(2025, 1, 1, tzinfo=timezone.utc),
query_index=5,
compressed_summary="Summary of conversation",
original_token_count=5000,
compressed_token_count=500,
compression_ratio=10.0,
model_used="gpt-4",
compression_prompt_version="v1.0",
)
defaults.update(overrides)
return CompressionMetadata(**defaults)
def test_to_dict_contains_all_fields(self):
meta = self._make_metadata()
d = meta.to_dict()
assert d["timestamp"] == datetime(2025, 1, 1, tzinfo=timezone.utc)
assert d["query_index"] == 5
assert d["compressed_summary"] == "Summary of conversation"
assert d["original_token_count"] == 5000
assert d["compressed_token_count"] == 500
assert d["compression_ratio"] == 10.0
assert d["model_used"] == "gpt-4"
assert d["compression_prompt_version"] == "v1.0"
def test_to_dict_returns_dict_type(self):
meta = self._make_metadata()
assert isinstance(meta.to_dict(), dict)
def test_to_dict_field_count(self):
meta = self._make_metadata()
d = meta.to_dict()
assert len(d) == 8
def test_attributes_accessible(self):
meta = self._make_metadata(query_index=10, compression_ratio=5.5)
assert meta.query_index == 10
assert meta.compression_ratio == 5.5
def test_zero_compressed_tokens(self):
meta = self._make_metadata(compressed_token_count=0, compression_ratio=0)
d = meta.to_dict()
assert d["compressed_token_count"] == 0
assert d["compression_ratio"] == 0
@pytest.mark.unit
class TestCompressionResult:
def test_success_with_compression(self):
meta = CompressionMetadata(
timestamp=datetime.now(timezone.utc),
query_index=3,
compressed_summary="summary",
original_token_count=1000,
compressed_token_count=100,
compression_ratio=10.0,
model_used="gpt-4",
compression_prompt_version="v1.0",
)
queries = [{"prompt": "q1", "response": "r1"}]
result = CompressionResult.success_with_compression("summary", queries, meta)
assert result.success is True
assert result.compressed_summary == "summary"
assert result.recent_queries == queries
assert result.metadata is meta
assert result.compression_performed is True
assert result.error is None
def test_success_no_compression(self):
queries = [{"prompt": "q1", "response": "r1"}]
result = CompressionResult.success_no_compression(queries)
assert result.success is True
assert result.compressed_summary is None
assert result.recent_queries == queries
assert result.metadata is None
assert result.compression_performed is False
assert result.error is None
def test_failure(self):
result = CompressionResult.failure("something went wrong")
assert result.success is False
assert result.error == "something went wrong"
assert result.compression_performed is False
assert result.compressed_summary is None
assert result.recent_queries == []
assert result.metadata is None
def test_as_history_extracts_prompt_response(self):
queries = [
{"prompt": "Hello", "response": "Hi", "extra": "ignored"},
{"prompt": "How?", "response": "Fine"},
]
result = CompressionResult.success_no_compression(queries)
history = result.as_history()
assert len(history) == 2
assert history[0] == {"prompt": "Hello", "response": "Hi"}
assert history[1] == {"prompt": "How?", "response": "Fine"}
def test_as_history_empty_queries(self):
result = CompressionResult.success_no_compression([])
assert result.as_history() == []
def test_default_recent_queries_is_empty_list(self):
result = CompressionResult(success=True)
assert result.recent_queries == []
assert result.as_history() == []
def test_success_no_compression_with_empty_list(self):
result = CompressionResult.success_no_compression([])
assert result.success is True
assert result.recent_queries == []
@@ -0,0 +1,307 @@
"""Tests for application/api/answer/services/continuation_service.py using pg_conn."""
from contextlib import contextmanager
from unittest.mock import patch
from uuid import uuid4
@contextmanager
def _patch_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.answer.services.continuation_service.db_readonly",
_yield,
), patch(
"application.api.answer.services.continuation_service.db_session",
_yield,
):
yield
class TestMakeSerializable:
def test_uuid_becomes_string(self):
from application.api.answer.services.continuation_service import (
_make_serializable,
)
u = uuid4()
assert _make_serializable(u) == str(u)
def test_dict_keys_stringified(self):
from application.api.answer.services.continuation_service import (
_make_serializable,
)
got = _make_serializable({42: "a", "b": 2})
assert got == {"42": "a", "b": 2}
def test_list_elements_recursively_serialized(self):
from application.api.answer.services.continuation_service import (
_make_serializable,
)
u = uuid4()
got = _make_serializable([u, {"x": u}, 1])
assert got == [str(u), {"x": str(u)}, 1]
def test_bytes_base64_encoded(self):
# Migrated from UTF-8-replace to base64 once the helper moved to
# the shared serialization module — base64 is lossless and round-
# trippable (UTF-8-replace silently corrupted binary payloads).
import base64
from application.api.answer.services.continuation_service import (
_make_serializable,
)
got = _make_serializable(b"hello")
assert got == base64.b64encode(b"hello").decode("ascii")
def test_bytes_arbitrary_binary_roundtrips(self):
import base64
from application.api.answer.services.continuation_service import (
_make_serializable,
)
raw = b"\xff\xfe\x00\x10"
got = _make_serializable(raw)
assert isinstance(got, str)
assert base64.b64decode(got) == raw
def test_passes_through_primitives(self):
from application.api.answer.services.continuation_service import (
_make_serializable,
)
assert _make_serializable("hello") == "hello"
assert _make_serializable(42) == 42
assert _make_serializable(None) is None
assert _make_serializable(True) is True
def test_datetime_becomes_iso_string(self):
# PG SELECT * pulls timestamptz columns through as datetime —
# tools_dict carries ``created_at``/``updated_at`` from user_tools
# rows, which would otherwise blow up json.dumps in pending_tool_state.
import json
from datetime import datetime, timezone
from application.api.answer.services.continuation_service import (
_make_serializable,
)
ts = datetime(2026, 5, 2, 12, 14, 32, tzinfo=timezone.utc)
got = _make_serializable(ts)
assert got == "2026-05-02T12:14:32+00:00"
json.dumps(got) # would raise on raw datetime
def test_datetime_nested_in_tools_dict(self):
# Mirrors the production failure: tools_dict is a dict-of-dicts
# where each tool row has timestamp fields buried under string keys.
import json
from datetime import datetime, timezone
from application.api.answer.services.continuation_service import (
_make_serializable,
)
ts = datetime(2026, 5, 2, 12, 14, 32, tzinfo=timezone.utc)
tools_dict = {
"0": {
"name": "mcp_tool",
"actions": [{"name": "search", "active": True}],
"created_at": ts,
"updated_at": ts,
}
}
got = _make_serializable(tools_dict)
json.dumps(got)
assert got["0"]["created_at"] == "2026-05-02T12:14:32+00:00"
def test_date_becomes_iso_string(self):
from datetime import date
from application.api.answer.services.continuation_service import (
_make_serializable,
)
assert _make_serializable(date(2026, 5, 2)) == "2026-05-02"
class TestContinuationServiceSaveLoad:
def test_save_and_load_state(self, pg_conn):
from application.api.answer.services.continuation_service import (
ContinuationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-cont"
conv = ConversationsRepository(pg_conn).create(user, name="c")
conv_id = str(conv["id"])
service = ContinuationService()
with _patch_db(pg_conn):
service.save_state(
conversation_id=conv_id,
user=user,
messages=[{"role": "user", "content": "hi"}],
pending_tool_calls=[{"id": "call-1", "name": "search"}],
tools_dict={"search": {"arg": "value"}},
tool_schemas=[{"name": "search", "params": {}}],
agent_config={"model_id": "gpt-4"},
client_tools=[{"name": "client-tool"}],
)
loaded = service.load_state(conv_id, user)
assert loaded is not None
assert loaded["messages"] == [{"role": "user", "content": "hi"}]
def test_load_state_returns_none_when_not_found(self, pg_conn):
from application.api.answer.services.continuation_service import (
ContinuationService,
)
service = ContinuationService()
with _patch_db(pg_conn):
got = service.load_state(
"00000000-0000-0000-0000-000000000000", "u",
)
assert got is None
def test_save_state_no_client_tools(self, pg_conn):
from application.api.answer.services.continuation_service import (
ContinuationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-no-client"
conv = ConversationsRepository(pg_conn).create(user, name="c")
conv_id = str(conv["id"])
service = ContinuationService()
with _patch_db(pg_conn):
service.save_state(
conversation_id=conv_id,
user=user,
messages=[],
pending_tool_calls=[{"id": "c"}],
tools_dict={},
tool_schemas=[],
agent_config={},
client_tools=None,
)
loaded = service.load_state(conv_id, user)
assert loaded is not None
def test_delete_state_returns_true_when_deleted(self, pg_conn):
from application.api.answer.services.continuation_service import (
ContinuationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-del"
conv = ConversationsRepository(pg_conn).create(user, name="c")
conv_id = str(conv["id"])
service = ContinuationService()
with _patch_db(pg_conn):
service.save_state(
conversation_id=conv_id,
user=user,
messages=[],
pending_tool_calls=[{"id": "c"}],
tools_dict={},
tool_schemas=[],
agent_config={},
)
got = service.delete_state(conv_id, user)
assert got is True
def test_delete_state_returns_false_when_missing(self, pg_conn):
from application.api.answer.services.continuation_service import (
ContinuationService,
)
service = ContinuationService()
with _patch_db(pg_conn):
got = service.delete_state(
"00000000-0000-0000-0000-000000000000", "u",
)
assert got is False
class TestContinuationServiceLegacyIdHandling:
"""An unresolvable Mongo ObjectId must not reach ``CAST(:conv_id AS uuid)``
in ``PendingToolStateRepository`` — that cast raises and aborts the
enclosing transaction. This can happen when an OpenAI-compatible client
echoes back a ``chatcmpl-<legacy_objectid>`` id from before the PG
cutover and the conversation hasn't been backfilled."""
LEGACY_OBJECTID = "507f1f77bcf86cd799439011"
def test_load_state_unresolvable_legacy_id_returns_none(self, pg_conn):
from application.api.answer.services.continuation_service import (
ContinuationService,
)
service = ContinuationService()
with _patch_db(pg_conn):
got = service.load_state(self.LEGACY_OBJECTID, "u")
assert got is None
def test_delete_state_unresolvable_legacy_id_returns_false(self, pg_conn):
from application.api.answer.services.continuation_service import (
ContinuationService,
)
service = ContinuationService()
with _patch_db(pg_conn):
got = service.delete_state(self.LEGACY_OBJECTID, "u")
assert got is False
def test_save_state_unresolvable_legacy_id_raises(self, pg_conn):
import pytest
from application.api.answer.services.continuation_service import (
ContinuationService,
)
service = ContinuationService()
with _patch_db(pg_conn):
with pytest.raises(ValueError):
service.save_state(
conversation_id=self.LEGACY_OBJECTID,
user="u",
messages=[],
pending_tool_calls=[{"id": "c"}],
tools_dict={},
tool_schemas=[],
agent_config={},
)
def test_load_state_resolves_backfilled_legacy_id(self, pg_conn):
from application.api.answer.services.continuation_service import (
ContinuationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "legacy-cont"
ConversationsRepository(pg_conn).create(
user, name="c", legacy_mongo_id=self.LEGACY_OBJECTID,
)
service = ContinuationService()
with _patch_db(pg_conn):
# Save via the legacy id — should resolve to the PG UUID internally.
service.save_state(
conversation_id=self.LEGACY_OBJECTID,
user=user,
messages=[{"role": "user", "content": "hi"}],
pending_tool_calls=[{"id": "c"}],
tools_dict={},
tool_schemas=[],
agent_config={},
)
# And load via the legacy id — should round-trip.
loaded = service.load_state(self.LEGACY_OBJECTID, user)
assert loaded is not None
assert loaded["messages"] == [{"role": "user", "content": "hi"}]
@@ -0,0 +1,873 @@
"""Tests for application/api/answer/services/conversation_service.py."""
from contextlib import contextmanager
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
@contextmanager
def _patch_db(conn):
@contextmanager
def _yield():
yield conn
with patch(
"application.api.answer.services.conversation_service.db_session",
_yield,
), patch(
"application.api.answer.services.conversation_service.db_readonly",
_yield,
):
yield
class TestConversationServiceGet:
def test_returns_none_when_no_conversation_id(self):
from application.api.answer.services.conversation_service import (
ConversationService,
)
assert ConversationService().get_conversation("", "u") is None
def test_returns_none_when_no_user_id(self):
from application.api.answer.services.conversation_service import (
ConversationService,
)
assert (
ConversationService().get_conversation(
"00000000-0000-0000-0000-000000000001", ""
)
is None
)
def test_returns_none_when_not_found(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
with _patch_db(pg_conn):
got = ConversationService().get_conversation(
"00000000-0000-0000-0000-000000000000", "u",
)
assert got is None
def test_returns_conversation_with_messages(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-get"
repo = ConversationsRepository(pg_conn)
conv = repo.create(user, name="hi")
conv_id = str(conv["id"])
repo.append_message(
conv_id, {"prompt": "q1", "response": "r1"}
)
with _patch_db(pg_conn):
got = ConversationService().get_conversation(conv_id, user)
assert got is not None
assert got["_id"] == conv_id
assert len(got["queries"]) == 1
def test_handles_exception_returns_none(self):
from application.api.answer.services.conversation_service import (
ConversationService,
)
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.answer.services.conversation_service.db_readonly",
_broken,
):
got = ConversationService().get_conversation("abc", "u")
assert got is None
class TestConversationServiceSave:
def test_raises_for_none_token(self):
from application.api.answer.services.conversation_service import (
ConversationService,
)
with pytest.raises(ValueError):
ConversationService().save_conversation(
conversation_id=None,
question="q", response="r", thought="", sources=[],
tool_calls=[], llm=MagicMock(), model_id="gpt",
decoded_token=None,
)
def test_raises_when_no_user_in_token(self):
from application.api.answer.services.conversation_service import (
ConversationService,
)
with pytest.raises(ValueError):
ConversationService().save_conversation(
conversation_id=None,
question="q", response="r", thought="", sources=[],
tool_calls=[], llm=MagicMock(), model_id="gpt",
decoded_token={},
)
def test_creates_new_conversation(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-save-new"
mock_llm = MagicMock()
mock_llm.gen.return_value = "Title"
with _patch_db(pg_conn):
conv_id = ConversationService().save_conversation(
conversation_id=None,
question="q1", response="r1", thought="",
sources=[{"text": "x" * 2000}], tool_calls=[],
llm=mock_llm, model_id="gpt-4",
decoded_token={"sub": user},
)
got = ConversationsRepository(pg_conn).get_any(conv_id, user)
assert got is not None
assert got["name"] == "Title"
def test_appends_message_to_existing(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-append"
repo = ConversationsRepository(pg_conn)
conv = repo.create(user, name="existing")
conv_id = str(conv["id"])
with _patch_db(pg_conn):
got = ConversationService().save_conversation(
conversation_id=conv_id,
question="q-new", response="r-new", thought="",
sources=[], tool_calls=[],
llm=MagicMock(), model_id="gpt-4",
decoded_token={"sub": user},
)
assert got == conv_id
messages = repo.get_messages(conv_id)
assert any(m["prompt"] == "q-new" for m in messages)
def test_updates_message_at_index(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-update-at"
repo = ConversationsRepository(pg_conn)
conv = repo.create(user, name="edit")
conv_id = str(conv["id"])
repo.append_message(conv_id, {"prompt": "old", "response": "old"})
with _patch_db(pg_conn):
got = ConversationService().save_conversation(
conversation_id=conv_id,
question="updated", response="reply", thought="",
sources=[], tool_calls=[],
llm=MagicMock(), model_id="gpt-4",
decoded_token={"sub": user},
index=0,
)
assert got == conv_id
messages = repo.get_messages(conv_id)
assert messages[0]["prompt"] == "updated"
def test_raises_when_conversation_missing(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
with _patch_db(pg_conn), pytest.raises(ValueError):
ConversationService().save_conversation(
conversation_id="00000000-0000-0000-0000-000000000000",
question="q", response="r", thought="",
sources=[], tool_calls=[],
llm=MagicMock(), model_id="gpt",
decoded_token={"sub": "u"},
)
def test_save_with_empty_llm_title_falls_back(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-empty-title"
mock_llm = MagicMock()
mock_llm.gen.return_value = ""
with _patch_db(pg_conn):
conv_id = ConversationService().save_conversation(
conversation_id=None,
question="q-fallback", response="r", thought="",
sources=[], tool_calls=[],
llm=mock_llm, model_id="gpt-4",
decoded_token={"sub": user},
)
got = ConversationsRepository(pg_conn).get_any(conv_id, user)
assert got["name"] == "q-fallback"
class TestSaveUserQuestion:
def test_creates_conversation_and_reserves_message(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
TERMINATED_RESPONSE_PLACEHOLDER,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-wal-new"
with _patch_db(pg_conn):
result = ConversationService().save_user_question(
conversation_id=None,
question="what is python?",
decoded_token={"sub": user},
)
assert result["conversation_id"]
assert result["message_id"]
assert result["request_id"]
repo = ConversationsRepository(pg_conn)
conv = repo.get_any(result["conversation_id"], user)
assert conv is not None
messages = repo.get_messages(result["conversation_id"])
assert len(messages) == 1
assert messages[0]["status"] == "pending"
assert messages[0]["prompt"] == "what is python?"
assert messages[0]["response"] == TERMINATED_RESPONSE_PLACEHOLDER
assert messages[0]["request_id"] == result["request_id"]
def test_appends_to_existing_conversation(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-wal-existing"
repo = ConversationsRepository(pg_conn)
conv = repo.create(user, name="hi")
conv_id = str(conv["id"])
with _patch_db(pg_conn):
result = ConversationService().save_user_question(
conversation_id=conv_id,
question="follow-up",
decoded_token={"sub": user},
)
assert result["conversation_id"] == conv_id
msgs = repo.get_messages(conv_id)
assert len(msgs) == 1
assert msgs[0]["prompt"] == "follow-up"
def test_raises_when_token_missing(self):
from application.api.answer.services.conversation_service import (
ConversationService,
)
with pytest.raises(ValueError):
ConversationService().save_user_question(
conversation_id=None,
question="q",
decoded_token=None,
)
def test_regenerate_at_index_replaces_old_message(self, pg_conn):
"""Regenerate at ``index`` truncates the old message *and
everything after* before reserving the placeholder, so the new
WAL row lands at ``position=index`` rather than appending at
the end. Pre-fix the WAL path appended unconditionally and the
old answer survived alongside the regenerated one.
"""
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-wal-regen"
repo = ConversationsRepository(pg_conn)
conv = repo.create(user, name="regen-test")
conv_id = str(conv["id"])
# Seed five completed messages at positions 0..4.
for i in range(5):
repo.append_message(
conv_id,
{
"prompt": f"q{i}",
"response": f"a{i}",
"thought": "",
"sources": [],
"tool_calls": [],
"metadata": {},
},
)
seeded = repo.get_messages(conv_id)
assert len(seeded) == 5
assert [m["position"] for m in seeded] == [0, 1, 2, 3, 4]
with _patch_db(pg_conn):
result = ConversationService().save_user_question(
conversation_id=conv_id,
question="q3-regen",
decoded_token={"sub": user},
index=3,
)
msgs = repo.get_messages(conv_id)
# Positions 0,1,2 from the seed plus the new placeholder at 3.
assert [m["position"] for m in msgs] == [0, 1, 2, 3]
# The placeholder carries the regenerated prompt.
regen = next(m for m in msgs if m["position"] == 3)
assert regen["prompt"] == "q3-regen"
assert regen["status"] == "pending"
assert str(regen["id"]) == result["message_id"]
# The old answer at index 3 is gone.
assert not any(m["response"] == "a3" for m in msgs)
# And anything after index 3 was truncated.
assert not any(m["prompt"] == "q4" for m in msgs)
def test_regenerate_at_index_zero_truncates_everything(self, pg_conn):
"""``index=0`` is a valid edge: it should drop every prior
message and reseat the placeholder at position 0.
"""
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-wal-regen-zero"
repo = ConversationsRepository(pg_conn)
conv = repo.create(user, name="regen-zero")
conv_id = str(conv["id"])
for i in range(3):
repo.append_message(
conv_id,
{
"prompt": f"old-{i}",
"response": f"old-a-{i}",
"thought": "",
"sources": [],
"tool_calls": [],
"metadata": {},
},
)
with _patch_db(pg_conn):
ConversationService().save_user_question(
conversation_id=conv_id,
question="fresh-from-start",
decoded_token={"sub": user},
index=0,
)
msgs = repo.get_messages(conv_id)
assert len(msgs) == 1
assert msgs[0]["position"] == 0
assert msgs[0]["prompt"] == "fresh-from-start"
def test_regenerate_index_ignored_without_conversation_id(self, pg_conn):
"""``index`` only makes sense against an existing conversation;
the create-then-reserve path silently treats it as a no-op
rather than truncating a freshly-created conversation.
"""
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-wal-regen-no-conv"
with _patch_db(pg_conn):
result = ConversationService().save_user_question(
conversation_id=None,
question="brand new q",
decoded_token={"sub": user},
index=2,
)
repo = ConversationsRepository(pg_conn)
msgs = repo.get_messages(result["conversation_id"])
assert len(msgs) == 1
assert msgs[0]["position"] == 0
assert msgs[0]["prompt"] == "brand new q"
def test_raises_when_conversation_unauthorized(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
with _patch_db(pg_conn), pytest.raises(ValueError):
ConversationService().save_user_question(
conversation_id="00000000-0000-0000-0000-000000000000",
question="q",
decoded_token={"sub": "u"},
)
class TestFinalizeMessage:
def test_finalizes_complete(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-fin-ok"
with _patch_db(pg_conn):
svc = ConversationService()
res = svc.save_user_question(
conversation_id=None,
question="q",
decoded_token={"sub": user},
)
from application.storage.db.repositories.conversations import (
MessageUpdateOutcome,
)
assert svc.finalize_message(
res["message_id"],
"real answer",
thought="thinking",
sources=[{"text": "x" * 2000, "title": "doc"}],
tool_calls=[{"name": "search"}],
model_id="gpt-4",
metadata={"foo": "bar"},
status="complete",
) is MessageUpdateOutcome.UPDATED
msgs = ConversationsRepository(pg_conn).get_messages(
res["conversation_id"],
)
assert msgs[0]["response"] == "real answer"
assert msgs[0]["status"] == "complete"
assert msgs[0]["thought"] == "thinking"
assert msgs[0]["model_id"] == "gpt-4"
# source text trimmed to 1000 chars at finalize time
assert len(msgs[0]["sources"][0]["text"]) == 1000
assert msgs[0]["metadata"]["foo"] == "bar"
def test_finalizes_failed_records_error(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-fin-fail"
with _patch_db(pg_conn):
svc = ConversationService()
res = svc.save_user_question(
conversation_id=None,
question="q",
decoded_token={"sub": user},
)
err = RuntimeError("provider down")
from application.storage.db.repositories.conversations import (
MessageUpdateOutcome,
)
assert svc.finalize_message(
res["message_id"],
"fallback text",
status="failed",
error=err,
) is MessageUpdateOutcome.UPDATED
msgs = ConversationsRepository(pg_conn).get_messages(
res["conversation_id"],
)
assert msgs[0]["status"] == "failed"
assert msgs[0]["metadata"]["error"] == "RuntimeError: provider down"
def test_finalize_flips_executed_tool_calls(self, pg_conn):
"""finalize_message must mark tool_call_attempts.status='executed'
rows as 'confirmed' for the same message_id."""
from sqlalchemy import text as sql_text
from application.api.answer.services.conversation_service import (
ConversationService,
)
user = "u-fin-tools"
with _patch_db(pg_conn):
svc = ConversationService()
res = svc.save_user_question(
conversation_id=None,
question="q",
decoded_token={"sub": user},
)
pg_conn.execute(
sql_text(
"INSERT INTO tool_call_attempts "
"(call_id, message_id, tool_name, action_name, arguments, status) "
"VALUES (:cid, CAST(:mid AS uuid), 't', 'a', '{}'::jsonb, 'executed')"
),
{"cid": "c1", "mid": res["message_id"]},
)
from application.storage.db.repositories.conversations import (
MessageUpdateOutcome,
)
assert svc.finalize_message(
res["message_id"], "ans", status="complete",
) is MessageUpdateOutcome.UPDATED
status = pg_conn.execute(
sql_text("SELECT status FROM tool_call_attempts WHERE call_id = :cid"),
{"cid": "c1"},
).scalar()
assert status == "confirmed"
def test_finalize_returns_not_found_for_unknown_message(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
MessageUpdateOutcome,
)
with _patch_db(pg_conn):
assert ConversationService().finalize_message(
"00000000-0000-0000-0000-000000000000",
"x",
status="complete",
) is MessageUpdateOutcome.NOT_FOUND
def test_finalize_rolls_back_tool_call_confirm_on_message_update_failure(
self, pg_conn
):
"""Atomicity: if ``update_message_by_id`` raises after the
tool_call_attempts confirm ran on the same connection, the
confirm rolls back with the rest of the transaction. The
``pg_conn`` fixture pins one connection inside an outer
rolled-back transaction; we patch ``db_session`` to wrap each
call in a SAVEPOINT so the production-code ``with`` block
actually rolls back when the message-update raises.
"""
from contextlib import contextmanager
from sqlalchemy import text as sql_text
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories import conversations as conv_module
user = "u-fin-rollback"
@contextmanager
def _savepoint_session():
nested = pg_conn.begin_nested()
try:
yield pg_conn
nested.commit()
except Exception:
nested.rollback()
raise
with patch(
"application.api.answer.services.conversation_service.db_session",
_savepoint_session,
), patch(
"application.api.answer.services.conversation_service.db_readonly",
_savepoint_session,
):
svc = ConversationService()
res = svc.save_user_question(
conversation_id=None,
question="q",
decoded_token={"sub": user},
)
pg_conn.execute(
sql_text(
"INSERT INTO tool_call_attempts "
"(call_id, message_id, tool_name, action_name, "
"arguments, status) VALUES (:cid, CAST(:mid AS uuid), "
"'t', 'a', '{}'::jsonb, 'executed')"
),
{"cid": "rb-1", "mid": res["message_id"]},
)
original = conv_module.ConversationsRepository.update_message_by_id
def boom(self, *args, **kwargs):
_ = (args, kwargs)
raise RuntimeError("simulated message-update failure")
conv_module.ConversationsRepository.update_message_by_id = boom
try:
with pytest.raises(RuntimeError):
svc.finalize_message(
res["message_id"], "answer", status="complete",
)
finally:
conv_module.ConversationsRepository.update_message_by_id = original
# The tool_call confirm rolled back: row stays at ``executed``.
status = pg_conn.execute(
sql_text(
"SELECT status FROM tool_call_attempts WHERE call_id = :cid"
),
{"cid": "rb-1"},
).scalar()
assert status == "executed"
msg_status = pg_conn.execute(
sql_text(
"SELECT status FROM conversation_messages "
"WHERE id = CAST(:mid AS uuid)"
),
{"mid": res["message_id"]},
).scalar()
assert msg_status == "pending"
def test_finalize_generates_title_when_provided(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-fin-title"
mock_llm = MagicMock()
mock_llm.gen.return_value = "Short Title"
with _patch_db(pg_conn):
svc = ConversationService()
res = svc.save_user_question(
conversation_id=None,
question="long question that becomes the fallback name",
decoded_token={"sub": user},
)
from application.storage.db.repositories.conversations import (
MessageUpdateOutcome,
)
assert svc.finalize_message(
res["message_id"],
"answer",
status="complete",
title_inputs={
"llm": mock_llm,
"question": "long question that becomes the fallback name",
"response": "answer",
"model_id": "gpt-4",
"fallback_name": (
"long question that becomes the fallback name"[:50]
),
},
) is MessageUpdateOutcome.UPDATED
repo = ConversationsRepository(pg_conn)
conv = repo.get_any(res["conversation_id"], user)
assert conv["name"] == "Short Title"
class TestSaveUserQuestionFinalizeFailedFlow:
"""LLM fails immediately; question stays queryable with status='failed' + error metadata."""
def test_failed_llm_leaves_question_persisted(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-acceptance"
with _patch_db(pg_conn):
svc = ConversationService()
# Simulates the WAL pre-persist before LLM call.
res = svc.save_user_question(
conversation_id=None,
question="why did this fail?",
decoded_token={"sub": user},
)
# Simulates the LLM raising immediately, caught by complete_stream.
try:
raise RuntimeError("upstream 503")
except RuntimeError as e:
svc.finalize_message(
res["message_id"],
"",
status="failed",
error=e,
)
msgs = ConversationsRepository(pg_conn).get_messages(
res["conversation_id"],
)
assert len(msgs) == 1
assert msgs[0]["prompt"] == "why did this fail?"
assert msgs[0]["status"] == "failed"
assert "RuntimeError" in msgs[0]["metadata"]["error"]
assert "upstream 503" in msgs[0]["metadata"]["error"]
class TestCompressionMetadata:
def test_update_compression_metadata(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-compress"
repo = ConversationsRepository(pg_conn)
conv = repo.create(user, name="c")
conv_id = str(conv["id"])
with _patch_db(pg_conn):
ConversationService().update_compression_metadata(
conv_id,
{
"timestamp": datetime.now(timezone.utc),
"compressed_summary": "summary",
"model_used": "gpt",
},
)
def test_update_compression_raises_on_error(self):
from application.api.answer.services.conversation_service import (
ConversationService,
)
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.answer.services.conversation_service.db_session",
_broken,
), pytest.raises(RuntimeError):
ConversationService().update_compression_metadata("abc", {})
def test_append_compression_message_skips_empty_summary(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
with _patch_db(pg_conn):
ConversationService().append_compression_message(
"any-id", {"compressed_summary": ""}
)
def test_append_compression_message_appends_summary(self, pg_conn):
from application.api.answer.services.conversation_service import (
ConversationService,
)
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
user = "u-append-cs"
repo = ConversationsRepository(pg_conn)
conv = repo.create(user, name="c")
conv_id = str(conv["id"])
with _patch_db(pg_conn):
ConversationService().append_compression_message(
conv_id,
{
"compressed_summary": "A summary",
"timestamp": datetime.now(timezone.utc),
"model_used": "gpt-4",
},
)
messages = repo.get_messages(conv_id)
assert any(m["response"] == "A summary" for m in messages)
def test_append_compression_message_swallows_error(self):
from application.api.answer.services.conversation_service import (
ConversationService,
)
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.answer.services.conversation_service.db_session",
_broken,
):
ConversationService().append_compression_message(
"abc", {"compressed_summary": "x"}
)
def test_get_compression_metadata_returns_none_for_missing(
self, pg_conn,
):
from application.api.answer.services.conversation_service import (
ConversationService,
)
with _patch_db(pg_conn):
got = ConversationService().get_compression_metadata(
"00000000-0000-0000-0000-000000000000"
)
assert got is None
def test_get_compression_metadata_non_uuid_does_not_raise(
self, pg_conn, caplog,
):
"""Non-UUID ids (legacy Mongo ObjectIds with no legacy_mongo_id row)
must return None without hitting ``CAST(:id AS uuid)`` — which
would raise and pollute logs with a stack trace every call."""
import logging
from application.api.answer.services.conversation_service import (
ConversationService,
)
with caplog.at_level(logging.ERROR):
with _patch_db(pg_conn):
got = ConversationService().get_compression_metadata(
"507f1f77bcf86cd799439011"
)
assert got is None
assert not any(
"Error getting compression metadata" in r.message for r in caplog.records
)
def test_get_compression_metadata_handles_exception(self):
from application.api.answer.services.conversation_service import (
ConversationService,
)
@contextmanager
def _broken():
raise RuntimeError("boom")
yield
with patch(
"application.api.answer.services.conversation_service.db_readonly",
_broken,
):
got = ConversationService().get_compression_metadata("abc")
assert got is None
@@ -0,0 +1,44 @@
"""Unit tests for ``resolve_persistence`` (persist + sidebar visibility)."""
import pytest
from application.api.answer.services.persistence_policy import (
VISIBILITY_HIDDEN,
VISIBILITY_LISTED,
resolve_persistence,
)
@pytest.mark.unit
class TestResolvePersistence:
def test_default_is_hidden(self):
persist, visibility = resolve_persistence()
assert persist is True
assert visibility == VISIBILITY_HIDDEN
def test_explicit_listed_lists(self):
persist, visibility = resolve_persistence(visibility_flag=VISIBILITY_LISTED)
assert persist is True
assert visibility == VISIBILITY_LISTED
def test_explicit_hidden_stays_hidden(self):
_, visibility = resolve_persistence(visibility_flag=VISIBILITY_HIDDEN)
assert visibility == VISIBILITY_HIDDEN
def test_unknown_visibility_value_falls_back_to_hidden(self):
_, visibility = resolve_persistence(visibility_flag="sidebar-please")
assert visibility == VISIBILITY_HIDDEN
def test_non_string_visibility_value_falls_back_to_hidden(self):
# Clients sending booleans (e.g. a stray ``visibility: true``) must
# not accidentally list — only the exact string "listed" opts in.
_, visibility = resolve_persistence(visibility_flag=True)
assert visibility == VISIBILITY_HIDDEN
def test_persist_opt_out(self):
persist, _ = resolve_persistence(persist_flag=False)
assert persist is False
def test_persist_defaults_true_when_listed(self):
persist, _ = resolve_persistence(visibility_flag=VISIBILITY_LISTED)
assert persist is True
@@ -0,0 +1,903 @@
import pytest
@pytest.mark.unit
class TestTemplateEngine:
def test_render_simple_template(self):
from application.templates.template_engine import TemplateEngine
engine = TemplateEngine()
result = engine.render("Hello {{ name }}", {"name": "World"})
assert result == "Hello World"
def test_render_with_namespace(self):
from application.templates.template_engine import TemplateEngine
engine = TemplateEngine()
context = {
"user": {"name": "Alice", "role": "admin"},
"system": {"date": "2025-10-22"},
}
result = engine.render(
"{{ user.name }} is a {{ user.role }} on {{ system.date }}", context
)
assert result == "Alice is a admin on 2025-10-22"
def test_render_empty_template(self):
from application.templates.template_engine import TemplateEngine
engine = TemplateEngine()
result = engine.render("", {"key": "value"})
assert result == ""
def test_render_template_without_variables(self):
from application.templates.template_engine import TemplateEngine
engine = TemplateEngine()
result = engine.render("Just plain text", {})
assert result == "Just plain text"
def test_render_undefined_variable_returns_empty_string(self):
from application.templates.template_engine import TemplateEngine
engine = TemplateEngine()
result = engine.render("Hello {{ undefined_var }}", {})
assert result == "Hello "
def test_render_syntax_error_raises_error(self):
from application.templates.template_engine import (
TemplateEngine,
TemplateRenderError,
)
engine = TemplateEngine()
with pytest.raises(TemplateRenderError, match="Template syntax error"):
engine.render("Hello {{ name", {"name": "World"})
def test_render_blocks_unsafe_attribute_access(self):
from application.templates.template_engine import (
TemplateEngine,
TemplateRenderError,
)
engine = TemplateEngine()
payload = "{{ cycler.__init__.__globals__.os.popen('id').read() }}"
with pytest.raises(TemplateRenderError, match="Template security violation"):
engine.render(payload, {})
def test_validate_template_valid(self):
from application.templates.template_engine import TemplateEngine
engine = TemplateEngine()
assert engine.validate_template("Valid {{ variable }}") is True
def test_validate_template_invalid(self):
from application.templates.template_engine import TemplateEngine
engine = TemplateEngine()
assert engine.validate_template("Invalid {{ variable") is False
def test_validate_empty_template(self):
from application.templates.template_engine import TemplateEngine
engine = TemplateEngine()
assert engine.validate_template("") is True
def test_extract_variables(self):
from application.templates.template_engine import TemplateEngine
engine = TemplateEngine()
template = "{{ user.name }} and {{ user.email }}"
result = engine.extract_variables(template)
assert isinstance(result, set)
@pytest.mark.unit
class TestSystemNamespace:
def test_system_namespace_build(self):
from application.templates.namespaces import SystemNamespace
builder = SystemNamespace()
context = builder.build(
request_id="req_123", user_id="user_456", extra_param="ignored"
)
assert context["request_id"] == "req_123"
assert context["user_id"] == "user_456"
assert "date" in context
assert "time" in context
assert "timestamp" in context
def test_system_namespace_generates_request_id(self):
from application.templates.namespaces import SystemNamespace
builder = SystemNamespace()
context = builder.build(user_id="user_123")
assert context["request_id"] is not None
assert len(context["request_id"]) > 0
def test_system_namespace_name(self):
from application.templates.namespaces import SystemNamespace
builder = SystemNamespace()
assert builder.namespace_name == "system"
def test_system_namespace_date_format(self):
from application.templates.namespaces import SystemNamespace
builder = SystemNamespace()
context = builder.build()
import re
assert re.match(r"\d{4}-\d{2}-\d{2}", context["date"])
assert re.match(r"\d{2}:\d{2}:\d{2}", context["time"])
@pytest.mark.unit
class TestPassthroughNamespace:
def test_passthrough_namespace_build(self):
from application.templates.namespaces import PassthroughNamespace
builder = PassthroughNamespace()
passthrough_data = {"company": "Acme", "user_name": "John", "count": 42}
context = builder.build(passthrough_data=passthrough_data)
assert context["company"] == "Acme"
assert context["user_name"] == "John"
assert context["count"] == 42
def test_passthrough_namespace_empty(self):
from application.templates.namespaces import PassthroughNamespace
builder = PassthroughNamespace()
context = builder.build(passthrough_data=None)
assert context == {}
def test_passthrough_namespace_filters_unsafe_values(self):
from application.templates.namespaces import PassthroughNamespace
builder = PassthroughNamespace()
passthrough_data = {
"safe_string": "value",
"unsafe_object": {"key": "value"},
"safe_bool": True,
"unsafe_list": [1, 2, 3],
"safe_float": 3.14,
}
context = builder.build(passthrough_data=passthrough_data)
assert context["safe_string"] == "value"
assert context["safe_bool"] is True
assert context["safe_float"] == 3.14
assert "unsafe_object" not in context
assert "unsafe_list" not in context
def test_passthrough_namespace_allows_none_values(self):
from application.templates.namespaces import PassthroughNamespace
builder = PassthroughNamespace()
passthrough_data = {"nullable_field": None}
context = builder.build(passthrough_data=passthrough_data)
assert context["nullable_field"] is None
def test_passthrough_namespace_name(self):
from application.templates.namespaces import PassthroughNamespace
builder = PassthroughNamespace()
assert builder.namespace_name == "passthrough"
@pytest.mark.unit
class TestSourceNamespace:
def test_source_namespace_build_with_docs(self):
from application.templates.namespaces import SourceNamespace
builder = SourceNamespace()
docs = [
{"text": "Doc 1", "filename": "file1.txt"},
{"text": "Doc 2", "filename": "file2.txt"},
]
docs_together = "Doc 1 content\n\nDoc 2 content"
context = builder.build(docs=docs, docs_together=docs_together)
assert context["documents"] == docs
assert context["count"] == 2
assert context["content"] == docs_together
assert context["summaries"] == docs_together
def test_source_namespace_build_empty(self):
from application.templates.namespaces import SourceNamespace
builder = SourceNamespace()
context = builder.build(docs=None, docs_together=None)
assert context == {}
def test_source_namespace_build_docs_only(self):
from application.templates.namespaces import SourceNamespace
builder = SourceNamespace()
docs = [{"text": "Doc 1"}]
context = builder.build(docs=docs)
assert context["documents"] == docs
assert context["count"] == 1
assert "content" not in context
def test_source_namespace_build_docs_together_only(self):
from application.templates.namespaces import SourceNamespace
builder = SourceNamespace()
docs_together = "Content here"
context = builder.build(docs_together=docs_together)
assert context["content"] == docs_together
assert context["summaries"] == docs_together
assert "documents" not in context
def test_source_namespace_name(self):
from application.templates.namespaces import SourceNamespace
builder = SourceNamespace()
assert builder.namespace_name == "source"
@pytest.mark.unit
class TestToolsNamespace:
def test_tools_namespace_build_with_memory_data(self):
from application.templates.namespaces import ToolsNamespace
builder = ToolsNamespace()
tools_data = {
"memory": {"root": "Files:\n- /notes.txt\n- /tasks.txt", "available": True}
}
context = builder.build(tools_data=tools_data)
assert context["memory"]["root"] == "Files:\n- /notes.txt\n- /tasks.txt"
assert context["memory"]["available"] is True
def test_tools_namespace_build_empty(self):
from application.templates.namespaces import ToolsNamespace
builder = ToolsNamespace()
context = builder.build(tools_data=None)
assert context == {}
def test_tools_namespace_build_multiple_tools(self):
from application.templates.namespaces import ToolsNamespace
builder = ToolsNamespace()
tools_data = {
"memory": {"root": "content", "available": True},
"search": {"results": ["result1", "result2"]},
"api": {"status": "success"},
}
context = builder.build(tools_data=tools_data)
assert "memory" in context
assert "search" in context
assert "api" in context
assert context["memory"]["root"] == "content"
assert context["search"]["results"] == ["result1", "result2"]
assert context["api"]["status"] == "success"
def test_tools_namespace_filters_unsafe_values(self):
from application.templates.namespaces import ToolsNamespace
builder = ToolsNamespace()
class UnsafeObject:
pass
tools_data = {"safe_tool": {"result": "success"}, "unsafe_tool": UnsafeObject()}
context = builder.build(tools_data=tools_data)
assert "safe_tool" in context
assert "unsafe_tool" not in context
def test_tools_namespace_name(self):
from application.templates.namespaces import ToolsNamespace
builder = ToolsNamespace()
assert builder.namespace_name == "tools"
def test_tools_namespace_with_empty_dict(self):
from application.templates.namespaces import ToolsNamespace
builder = ToolsNamespace()
context = builder.build(tools_data={})
assert context == {}
@pytest.mark.unit
class TestNamespaceManagerWithTools:
def test_namespace_manager_includes_tools_in_context(self):
from application.templates.namespaces import NamespaceManager
manager = NamespaceManager()
tools_data = {"memory": {"root": "content", "available": True}}
context = manager.build_context(tools_data=tools_data)
assert "tools" in context
assert context["tools"]["memory"]["root"] == "content"
def test_namespace_manager_build_context_all_namespaces(self):
from application.templates.namespaces import NamespaceManager
manager = NamespaceManager()
context = manager.build_context(
request_id="req_123",
user_id="user_456",
passthrough_data={"key": "value"},
docs_together="Document content",
tools_data={"memory": {"root": "notes"}},
)
assert "system" in context
assert "passthrough" in context
assert "source" in context
assert "tools" in context
assert context["tools"]["memory"]["root"] == "notes"
def test_namespace_manager_build_context_partial_data(self):
from application.templates.namespaces import NamespaceManager
manager = NamespaceManager()
context = manager.build_context(request_id="req_123")
assert "system" in context
assert context["system"]["request_id"] == "req_123"
class TestPromptRendererArtifactParent:
"""render_prompt forwards artifact_parent so artifacts.artifact(id) resolves for classic agents."""
def test_render_resolves_artifact_by_id_with_conversation_parent(self):
from unittest.mock import patch
from application.api.answer.services.prompt_renderer import PromptRenderer
row = {"id": "art-1", "current_version": 1, "kind": "document", "title": "Report"}
version = {"mime_type": "text/html", "filename": "report.html", "size": 12}
with patch(
"application.storage.db.repositories.artifacts.ArtifactsRepository"
) as repo_cls, patch("application.storage.db.session.db_readonly"):
repo = repo_cls.return_value
repo.get_artifact_in_parent.return_value = row
repo.get_version.return_value = version
rendered = PromptRenderer().render_prompt(
"file={{ artifacts.artifact('art-1').filename }}",
artifact_parent={"conversation_id": "conv-1"},
)
assert rendered == "file=report.html"
_, kwargs = repo.get_artifact_in_parent.call_args
assert kwargs.get("conversation_id") == "conv-1"
def test_render_without_parent_yields_empty_lookup(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
# No artifact_parent (e.g. a first-turn conversation_id is None) must not error;
# the lookup returns an empty mapping and the attribute renders blank.
rendered = PromptRenderer().render_prompt(
"[{{ artifacts.artifact('art-1').filename }}]"
)
assert rendered == "[]"
def test_namespace_manager_get_builder(self):
from application.templates.namespaces import NamespaceManager, SystemNamespace
manager = NamespaceManager()
builder = manager.get_builder("system")
assert isinstance(builder, SystemNamespace)
def test_namespace_manager_get_builder_nonexistent(self):
from application.templates.namespaces import NamespaceManager
manager = NamespaceManager()
builder = manager.get_builder("nonexistent")
assert builder is None
def test_namespace_manager_handles_builder_exceptions(self):
from unittest.mock import patch
from application.templates.namespaces import NamespaceManager
manager = NamespaceManager()
with patch.object(
manager._builders["system"],
"build",
side_effect=Exception("Builder error"),
):
context = manager.build_context()
# Namespace should be present but empty when builder fails
assert "system" in context
assert context["system"] == {}
@pytest.mark.unit
class TestPromptRenderer:
def test_render_prompt_with_template_syntax(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = "Hello {{ system.user_id }}, today is {{ system.date }}"
result = renderer.render_prompt(prompt, user_id="user_123")
assert "user_123" in result
assert "202" in result
def test_render_prompt_with_passthrough_data(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = "Company: {{ passthrough.company }}\nUser: {{ passthrough.user_name }}"
passthrough_data = {"company": "Acme", "user_name": "John"}
result = renderer.render_prompt(prompt, passthrough_data=passthrough_data)
assert "Company: Acme" in result
assert "User: John" in result
def test_render_prompt_with_source_docs(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = "Use this information:\n{{ source.content }}"
docs_together = "Important document content"
result = renderer.render_prompt(prompt, docs_together=docs_together)
assert "Use this information:" in result
assert "Important document content" in result
def test_render_prompt_empty_content(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
result = renderer.render_prompt("")
assert result == ""
def test_render_prompt_legacy_format_with_summaries(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = "Context: {summaries}\nQuestion: What is this?"
docs_together = "This is the document content"
result = renderer.render_prompt(prompt, docs_together=docs_together)
assert "Context: This is the document content" in result
def test_render_prompt_legacy_format_without_docs(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = "Context: {summaries}\nQuestion: What is this?"
result = renderer.render_prompt(prompt)
# The placeholder must never leak to the model when no docs exist.
assert "{summaries}" not in result
assert "Question: What is this?" in result
def test_render_prompt_combined_namespace_variables(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = "User: {{ passthrough.user }}, Date: {{ system.date }}, Docs: {{ source.content }}"
passthrough_data = {"user": "Alice"}
docs_together = "Doc content"
result = renderer.render_prompt(
prompt,
passthrough_data=passthrough_data,
docs_together=docs_together,
)
assert "User: Alice" in result
assert "Date: 202" in result
assert "Doc content" in result
def test_render_prompt_with_tools_data(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = "Memory contents:\n{{ tools.memory.root }}\n\nStatus: {{ tools.memory.available }}"
tools_data = {
"memory": {"root": "Files:\n- /notes.txt\n- /tasks.txt", "available": True}
}
result = renderer.render_prompt(prompt, tools_data=tools_data)
assert "Memory contents:" in result
assert "Files:" in result
assert "/notes.txt" in result
assert "/tasks.txt" in result
assert "Status: True" in result
def test_render_prompt_with_all_namespaces(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = """
System: {{ system.date }}
User: {{ passthrough.user }}
Docs: {{ source.content }}
Memory: {{ tools.memory.root }}
"""
passthrough_data = {"user": "Alice"}
docs_together = "Important docs"
tools_data = {"memory": {"root": "Notes content", "available": True}}
result = renderer.render_prompt(
prompt,
passthrough_data=passthrough_data,
docs_together=docs_together,
tools_data=tools_data,
)
assert "202" in result
assert "Alice" in result
assert "Important docs" in result
assert "Notes content" in result
def test_render_prompt_undefined_variable_returns_empty_string(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = "Hello {{ undefined_var }}"
result = renderer.render_prompt(prompt)
assert result == "Hello "
def test_render_prompt_with_undefined_variable_in_template(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = "Hello {{ undefined_name }}"
result = renderer.render_prompt(prompt)
assert result == "Hello "
def test_validate_template_valid(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
assert renderer.validate_template("Valid {{ variable }}") is True
def test_validate_template_invalid(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
assert renderer.validate_template("Invalid {{ variable") is False
def test_extract_variables(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
template = "{{ var1 }} and {{ var2 }}"
result = renderer.extract_variables(template)
assert isinstance(result, set)
def test_uses_template_syntax_detection(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
assert renderer._uses_template_syntax("Text with {{ var }}") is True
assert renderer._uses_template_syntax("Text with {var}") is False
assert renderer._uses_template_syntax("Plain text") is False
def test_apply_legacy_substitutions(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = "Use {summaries} to answer"
docs_together = "Important info"
result = renderer._apply_legacy_substitutions(prompt, docs_together)
assert "Use Important info to answer" in result
def test_apply_legacy_substitutions_without_docs(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = "Use {summaries} to answer"
result = renderer._apply_legacy_substitutions(prompt, None)
assert result == "Use to answer"
assert "{summaries}" not in result
@pytest.mark.unit
class TestPromptRendererIntegration:
def test_render_prompt_real_world_scenario(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = "You are helping {{ passthrough.company }}.\n\nUser: {{ passthrough.user_name }}\n\nRequest ID: {{ system.request_id }}\n\nDate: {{ system.date }}\n\nReference Documents:\n\n{{ source.content }}\n\nPlease answer the question professionally."
passthrough_data = {"company": "Tech Corp", "user_name": "Alice"}
docs_together = "Document 1: Technical specs\nDocument 2: Requirements"
result = renderer.render_prompt(
prompt,
request_id="req_123",
user_id="user_456",
passthrough_data=passthrough_data,
docs_together=docs_together,
)
assert "Tech Corp" in result
assert "Alice" in result
assert "req_123" in result
assert "Technical specs" in result
assert "professionally" in result
def test_render_prompt_multiple_doc_references(self):
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
prompt = """Documents: {{ source.content }} \n\nAlso summaries: {{ source.summaries }}"""
docs_together = "Content here"
result = renderer.render_prompt(prompt, docs_together=docs_together)
assert result.count("Content here") == 2
@pytest.mark.unit
@pytest.mark.skip(
reason="Uses removed MongoDB.get_client() + user_tools mongo collection; "
"needs rewrite against UserToolsRepository / pg_conn."
)
class TestStreamProcessorPromptRendering:
def test_stream_processor_pre_fetch_docs_none_doc_mode(self):
from application.api.answer.services.stream_processor import StreamProcessor
request_data = {"question": "Test question", "isNoneDoc": True}
processor = StreamProcessor(request_data, None)
docs_together, docs_list = processor.pre_fetch_docs("Test question")
assert docs_together is None
assert docs_list is None
def test_pre_fetch_tools_disabled_globally(self, monkeypatch):
from application.api.answer.services.stream_processor import StreamProcessor
from application.core.settings import settings
monkeypatch.setattr(settings, "ENABLE_TOOL_PREFETCH", False)
request_data = {"question": "test"}
processor = StreamProcessor(request_data, {"sub": "user1"})
result = processor.pre_fetch_tools()
assert result is None
def test_pre_fetch_tools_disabled_per_request(self):
from application.api.answer.services.stream_processor import StreamProcessor
request_data = {"question": "test", "disable_tool_prefetch": True}
processor = StreamProcessor(request_data, {"sub": "user1"})
result = processor.pre_fetch_tools()
assert result is None
def test_pre_fetch_tools_skips_tool_with_no_actions(self, mock_mongo_db):
from unittest.mock import MagicMock, patch
from application.api.answer.services.stream_processor import StreamProcessor
from application.core.settings import settings
db = mock_mongo_db[settings.MONGO_DB_NAME]
db["user_tools"].insert_one(
{
"name": "memory",
"user": "user1",
"status": True,
"config": {},
}
)
request_data = {"question": "test"}
processor = StreamProcessor(request_data, {"sub": "user1"})
with patch(
"application.agents.tools.tool_manager.ToolManager"
) as mock_manager_class:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
# Mock the tool instance
mock_tool = MagicMock()
mock_manager.load_tool.return_value = mock_tool
# Tool has no actions
mock_tool.get_actions_metadata.return_value = []
result = processor.pre_fetch_tools()
# Should return None when tool has no actions
assert result is None
def test_pre_fetch_tools_enabled_by_default(self, mock_mongo_db, monkeypatch):
from unittest.mock import MagicMock, patch
from application.api.answer.services.stream_processor import StreamProcessor
from application.core.settings import settings
db = mock_mongo_db[settings.MONGO_DB_NAME]
db["user_tools"].insert_one(
{
"name": "memory",
"user": "user1",
"status": True,
"config": {},
}
)
request_data = {"question": "test"}
processor = StreamProcessor(request_data, {"sub": "user1"})
with patch(
"application.agents.tools.tool_manager.ToolManager"
) as mock_manager_class:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
# Mock the tool instance returned by load_tool
mock_tool = MagicMock()
mock_manager.load_tool.return_value = mock_tool
# Mock get_actions_metadata on the tool instance
mock_tool.get_actions_metadata.return_value = [
{"name": "memory_ls", "description": "List files", "parameters": {"properties": {}}}
]
mock_tool.execute_action.return_value = "Directory: /\n- file.txt"
result = processor.pre_fetch_tools()
assert result is not None
assert "memory" in result
assert "memory_ls" in result["memory"]
def test_pre_fetch_tools_no_tools_configured(self):
from application.api.answer.services.stream_processor import StreamProcessor
request_data = {"question": "test"}
processor = StreamProcessor(request_data, {"sub": "user1"})
result = processor.pre_fetch_tools()
assert result is None
def test_pre_fetch_tools_memory_returns_error(self, mock_mongo_db):
from unittest.mock import MagicMock, patch
from application.api.answer.services.stream_processor import StreamProcessor
from application.core.settings import settings
db = mock_mongo_db[settings.MONGO_DB_NAME]
db["user_tools"].insert_one(
{
"name": "memory",
"user": "user1",
"status": True,
"config": {},
}
)
request_data = {"question": "test"}
processor = StreamProcessor(request_data, {"sub": "user1"})
with patch(
"application.agents.tools.tool_manager.ToolManager"
) as mock_manager_class:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
# Mock the tool instance
mock_tool = MagicMock()
mock_manager.load_tool.return_value = mock_tool
mock_tool.get_actions_metadata.return_value = [
{"name": "memory_ls", "description": "List files", "parameters": {"properties": {}}}
]
# Simulate execution error
mock_tool.execute_action.side_effect = Exception("Tool error")
result = processor.pre_fetch_tools()
# Should return None when all actions fail
assert result is None
def test_pre_fetch_tools_memory_returns_empty(self, mock_mongo_db):
from unittest.mock import MagicMock, patch
from application.api.answer.services.stream_processor import StreamProcessor
from application.core.settings import settings
db = mock_mongo_db[settings.MONGO_DB_NAME]
db["user_tools"].insert_one(
{
"name": "memory",
"user": "user1",
"status": True,
"config": {},
}
)
request_data = {"question": "test"}
processor = StreamProcessor(request_data, {"sub": "user1"})
with patch(
"application.agents.tools.tool_manager.ToolManager"
) as mock_manager_class:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
# Mock the tool instance
mock_tool = MagicMock()
mock_manager.load_tool.return_value = mock_tool
mock_tool.get_actions_metadata.return_value = [
{"name": "memory_ls", "description": "List files", "parameters": {"properties": {}}}
]
# Return empty string
mock_tool.execute_action.return_value = ""
result = processor.pre_fetch_tools()
# Empty result should still be included
assert result is not None
assert "memory" in result
@@ -0,0 +1,353 @@
"""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, [])
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,238 @@
"""Token-usage attribution tests for the always-on inline-persist model.
Persistence is owned by the per-call decorator in ``application.usage``.
``finalize_message`` no longer writes ``token_usage`` rows. These tests
exercise the decorator path through ``stream_token_usage`` /
``gen_token_usage``:
1. Every LLM call writes one row, regardless of whether the route saves
the conversation.
2. ``_token_usage_source`` on the LLM instance flows to the row's
``source`` column for cost-attribution dashboards.
3. ``_request_id`` on the LLM instance flows to the row's ``request_id``
column so ``count_in_range`` can DISTINCT-collapse multi-call agent
runs into a single request.
4. Calls with no attribution (no ``user_id`` and no ``user_api_key``)
warn and skip — the repository would otherwise raise on the
``token_usage_attribution_chk`` constraint.
"""
from __future__ import annotations
import logging
import uuid
from contextlib import contextmanager
from unittest.mock import patch
import pytest
from sqlalchemy import text
@contextmanager
def _patch_db_session_for(modules, conn):
"""Reroute every named module's ``db_session`` to ``conn``."""
@contextmanager
def _yield():
yield conn
patches = [patch(f"{m}.db_session", _yield) for m in modules]
for p in patches:
p.start()
try:
yield
finally:
for p in patches:
p.stop()
def _seed_user(conn) -> str:
user_id = str(uuid.uuid4())
conn.execute(
text(
"INSERT INTO users (user_id) VALUES (:u) "
"ON CONFLICT (user_id) DO NOTHING"
),
{"u": user_id},
)
return user_id
@pytest.mark.unit
class TestDecoratorAlwaysPersists:
"""Per-call inline persistence — no opt-in flag."""
def test_primary_stream_writes_agent_stream_row(self, pg_conn):
from application.usage import stream_token_usage
user_id = _seed_user(pg_conn)
class _PrimaryLLM:
decoded_token = {"sub": user_id}
user_api_key = None
agent_id = None
def __init__(self):
self.token_usage = {"prompt_tokens": 0, "generated_tokens": 0}
@stream_token_usage
def _raw(self, model, messages, stream, tools, **kwargs):
yield "chunk-a"
yield "chunk-b"
llm = _PrimaryLLM()
with _patch_db_session_for(("application.usage",), pg_conn):
for _ in llm._raw(
"m", [{"role": "user", "content": "hi"}], True, None,
):
pass
row = pg_conn.execute(
text(
"SELECT prompt_tokens, generated_tokens, source, request_id "
"FROM token_usage WHERE user_id = :u"
),
{"u": user_id},
).fetchone()
assert row is not None
assert row[2] == "agent_stream"
assert row[3] is None # No request_id stamped on this LLM.
assert row[0] > 0
assert row[1] > 0
def test_side_channel_source_flows_to_row(self, pg_conn):
"""``_token_usage_source`` overrides the default ``agent_stream``."""
from application.usage import stream_token_usage
user_id = _seed_user(pg_conn)
class _RagLLM:
decoded_token = {"sub": user_id}
user_api_key = None
agent_id = None
_token_usage_source = "rag_condense"
def __init__(self):
self.token_usage = {"prompt_tokens": 0, "generated_tokens": 0}
@stream_token_usage
def _raw(self, model, messages, stream, tools, **kwargs):
yield "chunk"
llm = _RagLLM()
with _patch_db_session_for(("application.usage",), pg_conn):
for _ in llm._raw("m", [{"role": "user", "content": "q"}], True, None):
pass
row = pg_conn.execute(
text(
"SELECT source FROM token_usage WHERE user_id = :u"
),
{"u": user_id},
).fetchone()
assert row is not None
assert row[0] == "rag_condense"
def test_request_id_propagates_to_row(self, pg_conn):
"""``_request_id`` on the LLM (stamped by the route) lands in
``token_usage.request_id`` so ``count_in_range`` can DISTINCT it.
"""
from application.usage import stream_token_usage
user_id = _seed_user(pg_conn)
request_id = f"req-{uuid.uuid4().hex[:12]}"
class _PrimaryLLM:
decoded_token = {"sub": user_id}
user_api_key = None
agent_id = None
def __init__(self):
self.token_usage = {"prompt_tokens": 0, "generated_tokens": 0}
self._request_id = request_id
@stream_token_usage
def _raw(self, model, messages, stream, tools, **kwargs):
yield "chunk"
llm = _PrimaryLLM()
with _patch_db_session_for(("application.usage",), pg_conn):
# Call twice — the route invokes the LLM once per tool round.
for _ in llm._raw("m", [{"role": "user", "content": "q"}], True, None):
pass
for _ in llm._raw("m", [{"role": "user", "content": "q2"}], True, None):
pass
rows = pg_conn.execute(
text(
"SELECT request_id FROM token_usage WHERE user_id = :u"
),
{"u": user_id},
).fetchall()
assert len(rows) == 2
assert all(r[0] == request_id for r in rows)
def test_zero_count_call_is_skipped(self, pg_conn):
from application.usage import gen_token_usage
user_id = _seed_user(pg_conn)
class _EmptyLLM:
decoded_token = {"sub": user_id}
user_api_key = None
agent_id = None
def __init__(self):
self.token_usage = {"prompt_tokens": 0, "generated_tokens": 0}
@gen_token_usage
def _raw(self, model, messages, stream, tools, **kwargs):
return None # empty result → 0 generated tokens, 0 prompt tokens
llm = _EmptyLLM()
with _patch_db_session_for(("application.usage",), pg_conn):
llm._raw("m", [], False, None)
n = pg_conn.execute(
text("SELECT count(*) FROM token_usage WHERE user_id = :u"),
{"u": user_id},
).scalar()
assert n == 0
def test_no_attribution_warns_and_skips(self, pg_conn, caplog):
"""No user_id and no api_key → log a warning, don't insert.
The repository would otherwise raise on the attribution CHECK
constraint; the decorator skips before that to keep the stream
running.
"""
from application.usage import stream_token_usage
class _OrphanLLM:
decoded_token = None
user_api_key = None
agent_id = None
def __init__(self):
self.token_usage = {"prompt_tokens": 0, "generated_tokens": 0}
@stream_token_usage
def _raw(self, model, messages, stream, tools, **kwargs):
yield "chunk"
llm = _OrphanLLM()
with _patch_db_session_for(
("application.usage",), pg_conn,
), caplog.at_level(logging.WARNING, logger="application.usage"):
for _ in llm._raw("m", [{"role": "user", "content": "q"}], True, None):
pass
n = pg_conn.execute(text("SELECT count(*) FROM token_usage")).scalar()
# New attribution rows specifically for this orphan path: nothing
# should land. The fixture pins state, so an existing baseline is
# 0 by default.
assert n == 0
assert any(
"no user_id/api_key" in r.message
for r in caplog.records
)