fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
1068 lines
39 KiB
Python
1068 lines
39 KiB
Python
"""Targeted tests for application/api/answer/services/stream_processor.py.
|
||
|
||
Tests the ``get_prompt`` helper and simpler StreamProcessor methods against
|
||
real ephemeral Postgres.
|
||
"""
|
||
|
||
from contextlib import contextmanager
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
|
||
|
||
@contextmanager
|
||
def _patch_db(conn):
|
||
@contextmanager
|
||
def _yield():
|
||
yield conn
|
||
|
||
with patch(
|
||
"application.api.answer.services.stream_processor.db_readonly", _yield
|
||
), patch(
|
||
"application.api.answer.services.stream_processor.db_session", _yield
|
||
):
|
||
yield
|
||
|
||
|
||
class TestGetPrompt:
|
||
def test_default_preset(self):
|
||
from application.api.answer.services.stream_processor import get_prompt
|
||
got = get_prompt("default")
|
||
assert isinstance(got, str)
|
||
assert len(got) > 0
|
||
|
||
def test_creative_preset(self):
|
||
from application.api.answer.services.stream_processor import get_prompt
|
||
got = get_prompt("creative")
|
||
assert isinstance(got, str) and len(got) > 0
|
||
|
||
def test_strict_preset(self):
|
||
from application.api.answer.services.stream_processor import get_prompt
|
||
got = get_prompt("strict")
|
||
assert isinstance(got, str) and len(got) > 0
|
||
|
||
def test_agentic_default_preset(self):
|
||
from application.api.answer.services.stream_processor import get_prompt
|
||
got = get_prompt("agentic_default")
|
||
assert isinstance(got, str) and len(got) > 0
|
||
|
||
def test_none_defaults_to_default(self):
|
||
from application.api.answer.services.stream_processor import get_prompt
|
||
got = get_prompt(None)
|
||
assert isinstance(got, str) and len(got) > 0
|
||
|
||
def test_empty_string_defaults_to_default(self):
|
||
from application.api.answer.services.stream_processor import get_prompt
|
||
assert get_prompt("") == get_prompt("default")
|
||
|
||
def test_non_string_id_converted(self):
|
||
from application.api.answer.services.stream_processor import get_prompt
|
||
# A UUID object would be stringified; use an int to test the branch
|
||
with pytest.raises(ValueError):
|
||
# Int converts to str "42" which isn't a preset, and will
|
||
# raise ValueError once lookup fails
|
||
get_prompt(42)
|
||
|
||
def test_unknown_prompt_id_raises(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import get_prompt
|
||
with _patch_db(pg_conn), pytest.raises(ValueError):
|
||
get_prompt("00000000-0000-0000-0000-000000000000")
|
||
|
||
def test_legacy_id_unknown_raises(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import get_prompt
|
||
with _patch_db(pg_conn), pytest.raises(ValueError):
|
||
get_prompt("507f1f77bcf86cd799439011")
|
||
|
||
def test_uuid_lookup_returns_content(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import get_prompt
|
||
from application.storage.db.repositories.prompts import (
|
||
PromptsRepository,
|
||
)
|
||
|
||
prompt = PromptsRepository(pg_conn).create(
|
||
"u", "myprompt", "custom prompt content",
|
||
)
|
||
with _patch_db(pg_conn):
|
||
got = get_prompt(str(prompt["id"]))
|
||
assert got == "custom prompt content"
|
||
|
||
|
||
class TestStreamProcessorInit:
|
||
def test_basic_init(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
data = {"question": "hi", "conversation_id": "conv-1"}
|
||
token = {"sub": "user-abc"}
|
||
sp = StreamProcessor(data, token)
|
||
assert sp.data == data
|
||
assert sp.decoded_token == token
|
||
assert sp.initial_user_id == "user-abc"
|
||
assert sp.conversation_id == "conv-1"
|
||
|
||
def test_init_no_token(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"question": "hi"}, None)
|
||
assert sp.decoded_token is None
|
||
assert sp.initial_user_id is None
|
||
assert sp.conversation_id is None
|
||
|
||
def test_init_sets_agent_id_from_data(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
data = {"question": "hi", "agent_id": "agent-xyz"}
|
||
sp = StreamProcessor(data, {"sub": "u"})
|
||
assert sp.agent_id == "agent-xyz"
|
||
|
||
|
||
class TestLoadConversationHistory:
|
||
def test_no_conversation_id_uses_request_history(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
import json as _json
|
||
|
||
data = {
|
||
"question": "hi",
|
||
"history": _json.dumps([{"prompt": "a", "response": "b"}]),
|
||
}
|
||
sp = StreamProcessor(data, {"sub": "u"})
|
||
sp._load_conversation_history()
|
||
assert len(sp.history) == 1
|
||
|
||
def test_loads_existing_conversation_history(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
from application.storage.db.repositories.conversations import (
|
||
ConversationsRepository,
|
||
)
|
||
|
||
user = "u-load-hist"
|
||
repo = ConversationsRepository(pg_conn)
|
||
conv = repo.create(user, name="c")
|
||
conv_id = str(conv["id"])
|
||
repo.append_message(
|
||
conv_id,
|
||
{"prompt": "q1", "response": "r1"},
|
||
)
|
||
repo.append_message(
|
||
conv_id,
|
||
{"prompt": "q2", "response": "r2"},
|
||
)
|
||
|
||
sp = StreamProcessor(
|
||
{"question": "x", "conversation_id": conv_id},
|
||
{"sub": user},
|
||
)
|
||
# Also patch conversation_service.get_conversation's DB accessor
|
||
with _patch_db(pg_conn), patch(
|
||
"application.api.answer.services.conversation_service.db_readonly",
|
||
) as mock_readonly:
|
||
@contextmanager
|
||
def _yield():
|
||
yield pg_conn
|
||
mock_readonly.side_effect = _yield
|
||
sp._load_conversation_history()
|
||
assert len(sp.history) == 2
|
||
|
||
def test_unauthorized_conversation_raises(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
from application.storage.db.repositories.conversations import (
|
||
ConversationsRepository,
|
||
)
|
||
|
||
repo = ConversationsRepository(pg_conn)
|
||
conv = repo.create("owner-user", name="c")
|
||
sp = StreamProcessor(
|
||
{"question": "hack", "conversation_id": str(conv["id"])},
|
||
{"sub": "hacker"},
|
||
)
|
||
with _patch_db(pg_conn), patch(
|
||
"application.api.answer.services.conversation_service.db_readonly"
|
||
) as mock_readonly:
|
||
@contextmanager
|
||
def _yield():
|
||
yield pg_conn
|
||
mock_readonly.side_effect = _yield
|
||
with pytest.raises(ValueError):
|
||
sp._load_conversation_history()
|
||
|
||
|
||
class TestHasActiveDocs:
|
||
def test_false_when_no_active_docs(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"question": "q"}, {"sub": "u"})
|
||
sp.source = {}
|
||
sp.all_sources = []
|
||
assert sp._has_active_docs() is False
|
||
|
||
def test_true_when_source_active(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"question": "q"}, {"sub": "u"})
|
||
sp.source = {"active_docs": "abc"}
|
||
assert sp._has_active_docs() is True
|
||
|
||
def test_false_when_source_empty(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"question": "q"}, {"sub": "u"})
|
||
sp.source = None
|
||
assert sp._has_active_docs() is False
|
||
|
||
def test_default_returns_false(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"question": "q"}, {"sub": "u"})
|
||
sp.source = {"active_docs": "default"}
|
||
sp.all_sources = []
|
||
assert sp._has_active_docs() is False
|
||
|
||
|
||
class TestProcessAttachments:
|
||
def test_no_attachments(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"question": "q"}, {"sub": "u"})
|
||
# attachment_ids not set
|
||
with _patch_db(pg_conn):
|
||
sp._process_attachments()
|
||
assert sp.attachments == []
|
||
|
||
def test_retrieves_attachments_by_id(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
from application.storage.db.repositories.attachments import (
|
||
AttachmentsRepository,
|
||
)
|
||
|
||
user = "u-atts"
|
||
att = AttachmentsRepository(pg_conn).create(
|
||
user, "doc.txt", "/path",
|
||
content="content here",
|
||
size=100,
|
||
)
|
||
sp = StreamProcessor(
|
||
{"question": "q", "attachments": [str(att["id"])]},
|
||
{"sub": user},
|
||
)
|
||
with _patch_db(pg_conn):
|
||
sp._process_attachments()
|
||
assert len(sp.attachments) == 1
|
||
assert sp.attachments[0]["content"] == "content here"
|
||
|
||
|
||
class TestGetAttachmentsContent:
|
||
def test_empty_list(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"question": "q"}, {"sub": "u"})
|
||
with _patch_db(pg_conn):
|
||
got = sp._get_attachments_content([], "u")
|
||
assert got == []
|
||
|
||
def test_skips_missing_attachments(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"question": "q"}, {"sub": "u"})
|
||
with _patch_db(pg_conn):
|
||
got = sp._get_attachments_content(
|
||
["00000000-0000-0000-0000-000000000000"], "u",
|
||
)
|
||
assert got == []
|
||
|
||
|
||
class TestResolveAgentId:
|
||
def test_returns_agent_id_from_request(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"agent_id": "req-agent"}, {"sub": "u"})
|
||
assert sp._resolve_agent_id() == "req-agent"
|
||
|
||
def test_returns_none_if_not_set(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
assert sp._resolve_agent_id() is None
|
||
|
||
|
||
class TestGetAgentKey:
|
||
def test_returns_tuple_for_none_agent_id(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
key, is_shared, tok = sp._get_agent_key(None, "u")
|
||
assert key is None and is_shared is False and tok is None
|
||
|
||
def test_raises_for_missing_agent(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
with _patch_db(pg_conn), pytest.raises(Exception):
|
||
sp._get_agent_key(
|
||
"00000000-0000-0000-0000-000000000000", "u",
|
||
)
|
||
|
||
def test_returns_key_for_owned_agent(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
from application.storage.db.repositories.agents import AgentsRepository
|
||
|
||
agent = AgentsRepository(pg_conn).create(
|
||
"owner", "a", "published", key="the-key",
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "owner"})
|
||
with _patch_db(pg_conn):
|
||
key, shared, tok = sp._get_agent_key(str(agent["id"]), "owner")
|
||
assert key == "the-key"
|
||
assert shared is False
|
||
|
||
def test_raises_on_unauthorized_access(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
from application.storage.db.repositories.agents import AgentsRepository
|
||
|
||
agent = AgentsRepository(pg_conn).create(
|
||
"owner", "a", "published", key="k", shared=False,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "not-owner"})
|
||
with _patch_db(pg_conn), pytest.raises(Exception):
|
||
sp._get_agent_key(str(agent["id"]), "not-owner")
|
||
|
||
|
||
class TestConfigureSource:
|
||
def test_agent_data_with_sources_list(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp._agent_data = {
|
||
"sources": [
|
||
{"id": "s1", "retriever": "classic"},
|
||
{"id": "default"},
|
||
],
|
||
}
|
||
sp._configure_source()
|
||
assert sp.source == {"active_docs": ["s1"]}
|
||
|
||
def test_agent_data_with_single_source(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp._agent_data = {"source": "src-1", "retriever": "classic"}
|
||
sp._configure_source()
|
||
assert sp.source == {"active_docs": "src-1"}
|
||
|
||
def test_agent_data_default_source(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp._agent_data = {"source": "default"}
|
||
sp._configure_source()
|
||
assert sp.source == {}
|
||
|
||
def test_request_active_docs_used(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"active_docs": "abc"}, {"sub": "u"})
|
||
sp._configure_source()
|
||
assert sp.source == {"active_docs": "abc"}
|
||
|
||
def test_request_active_docs_default(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"active_docs": "default"}, {"sub": "u"})
|
||
sp._configure_source()
|
||
assert sp.source == {}
|
||
|
||
def test_no_data_empty_source(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp._configure_source()
|
||
assert sp.source == {}
|
||
|
||
|
||
class TestConfigureRetriever:
|
||
def test_defaults(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp._configure_retriever()
|
||
assert sp.retriever_config["retriever_name"] == "classic"
|
||
assert sp.retriever_config["chunks"] == 2
|
||
|
||
def test_agent_overrides(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp._agent_data = {"retriever": "hybrid_search", "chunks": 5}
|
||
sp._configure_retriever()
|
||
assert sp.retriever_config["retriever_name"] == "hybrid_search"
|
||
assert sp.retriever_config["chunks"] == 5
|
||
|
||
def test_agent_wins_over_request_on_agent_bound(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor(
|
||
{"retriever": "duckdb", "chunks": 7}, {"sub": "u"},
|
||
)
|
||
sp._agent_data = {"retriever": "hybrid_search", "chunks": 5}
|
||
sp._configure_retriever()
|
||
assert sp.retriever_config["retriever_name"] == "hybrid_search"
|
||
assert sp.retriever_config["chunks"] == 5
|
||
|
||
def test_body_wins_on_agentless(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor(
|
||
{"retriever": "duckdb", "chunks": 7}, {"sub": "u"},
|
||
)
|
||
sp._configure_retriever()
|
||
assert sp.retriever_config["retriever_name"] == "duckdb"
|
||
assert sp.retriever_config["chunks"] == 7
|
||
|
||
def test_agent_bound_drops_body_chunks_and_retriever(self):
|
||
# Missing agent values fall back to system defaults, not body's.
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor(
|
||
{"retriever": "duckdb", "chunks": 7}, {"sub": "u"},
|
||
)
|
||
sp._agent_data = {}
|
||
sp._configure_retriever()
|
||
assert sp.retriever_config["retriever_name"] == "classic"
|
||
assert sp.retriever_config["chunks"] == 2
|
||
|
||
def test_invalid_agent_chunks_falls_back(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp._agent_data = {"chunks": "not-a-number"}
|
||
sp._configure_retriever()
|
||
assert sp.retriever_config["chunks"] == 2
|
||
|
||
def test_invalid_request_chunks_falls_back(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"chunks": "abc"}, {"sub": "u"})
|
||
sp._configure_retriever()
|
||
assert sp.retriever_config["chunks"] == 2
|
||
|
||
def test_isnonedoc_without_api_key_sets_chunks_to_0(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"isNoneDoc": True}, {"sub": "u"})
|
||
sp.agent_key = None
|
||
sp._configure_retriever()
|
||
assert sp.retriever_config["chunks"] == 0
|
||
|
||
|
||
class TestGetPromptContent:
|
||
def test_gets_from_agent_config(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
from application.storage.db.repositories.prompts import (
|
||
PromptsRepository,
|
||
)
|
||
|
||
prompt = PromptsRepository(pg_conn).create(
|
||
"u", "p1", "My prompt content",
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp.agent_config = {"prompt_id": str(prompt["id"])}
|
||
|
||
with _patch_db(pg_conn):
|
||
content = sp._get_prompt_content()
|
||
assert content == "My prompt content"
|
||
|
||
def test_returns_none_on_missing(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp.agent_config = {
|
||
"prompt_id": "00000000-0000-0000-0000-000000000000",
|
||
}
|
||
with _patch_db(pg_conn):
|
||
content = sp._get_prompt_content()
|
||
assert content is None
|
||
|
||
def test_caches_prompt_content(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp._prompt_content = "cached"
|
||
# Even with no agent_config, cached value returned
|
||
assert sp._get_prompt_content() == "cached"
|
||
|
||
def test_agentic_agent_gets_agentic_preset(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp.agent_config = {"prompt_id": "default", "agent_type": "agentic"}
|
||
content = sp._get_prompt_content()
|
||
assert "`search` tool" in content
|
||
assert "source.summaries" not in content
|
||
|
||
def test_research_agent_gets_agentic_preset(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp.agent_config = {"prompt_id": "strict", "agent_type": "research"}
|
||
content = sp._get_prompt_content()
|
||
assert "`search` tool" in content
|
||
|
||
def test_classic_agent_gets_classic_preset(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp.agent_config = {"prompt_id": "default", "agent_type": "classic"}
|
||
content = sp._get_prompt_content()
|
||
assert "source.summaries" in content
|
||
|
||
def test_null_prompt_id_agentic_agent_gets_agentic_preset(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
# PG ``agents.prompt_id`` is NULL for agents that never chose a
|
||
# prompt — the agentic swap must still apply.
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp.agent_config = {"prompt_id": None, "agent_type": "agentic"}
|
||
content = sp._get_prompt_content()
|
||
assert content is not None
|
||
assert "`search` tool" in content
|
||
assert "source.summaries" not in content
|
||
|
||
def test_null_prompt_id_classic_agent_gets_default_preset(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp.agent_config = {"prompt_id": None}
|
||
content = sp._get_prompt_content()
|
||
assert content is not None
|
||
assert "source.summaries" in content
|
||
|
||
|
||
class TestPreFetchDocs:
|
||
def test_skips_when_no_active_docs(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp.source = {}
|
||
docs, raw = sp.pre_fetch_docs("question")
|
||
assert docs is None and raw is None
|
||
|
||
def test_skips_when_isnonedoc_no_agent(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"isNoneDoc": True}, {"sub": "u"})
|
||
sp.source = {"active_docs": "abc"} # would normally be active
|
||
sp.agent_id = None
|
||
docs, raw = sp.pre_fetch_docs("q")
|
||
assert docs is None and raw is None
|
||
|
||
def test_handles_retriever_exception(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
sp.source = {"active_docs": "src"}
|
||
with patch(
|
||
"application.api.answer.services.stream_processor.StreamProcessor.create_retriever",
|
||
side_effect=RuntimeError("boom"),
|
||
):
|
||
docs, raw = sp.pre_fetch_docs("q")
|
||
assert docs is None and raw is None
|
||
|
||
|
||
class TestPreFetchTools:
|
||
def test_disabled_globally_returns_none(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u"})
|
||
with patch(
|
||
"application.api.answer.services.stream_processor.settings.ENABLE_TOOL_PREFETCH",
|
||
False,
|
||
):
|
||
got = sp.pre_fetch_tools()
|
||
assert got is None
|
||
|
||
def test_disabled_per_request(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"disable_tool_prefetch": True}, {"sub": "u"})
|
||
with patch(
|
||
"application.api.answer.services.stream_processor.settings.ENABLE_TOOL_PREFETCH",
|
||
True,
|
||
):
|
||
got = sp.pre_fetch_tools()
|
||
assert got is None
|
||
|
||
def test_no_template_skips_default_tool_prefetch(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
|
||
sp = StreamProcessor({}, {"sub": "no-tools-user"})
|
||
sp._prompt_content = "No template syntax here"
|
||
with _patch_db(pg_conn), patch(
|
||
"application.api.answer.services.stream_processor.settings.ENABLE_TOOL_PREFETCH",
|
||
True,
|
||
):
|
||
got = sp.pre_fetch_tools()
|
||
assert got is None
|
||
|
||
def test_unresolvable_prompt_prefetches_only_explicit_rows(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
from application.storage.db.repositories.user_tools import (
|
||
UserToolsRepository,
|
||
)
|
||
|
||
UserToolsRepository(pg_conn).create(
|
||
user_id="u-explicit-prefetch", name="read_webpage", status=True
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u-explicit-prefetch"})
|
||
# A broken custom prompt id disables action filtering; explicit
|
||
# rows still prefetch, defaults stay skipped.
|
||
sp.agent_config = {
|
||
"prompt_id": "00000000-0000-0000-0000-000000000000"
|
||
}
|
||
fetched = []
|
||
|
||
def _fake_fetch(tool_doc, required_actions):
|
||
fetched.append(tool_doc)
|
||
return {"ok": True}
|
||
|
||
with _patch_db(pg_conn), patch(
|
||
"application.api.answer.services.stream_processor.settings.ENABLE_TOOL_PREFETCH",
|
||
True,
|
||
), patch.object(sp, "_fetch_tool_data", _fake_fetch):
|
||
got = sp.pre_fetch_tools()
|
||
assert got is not None
|
||
assert "read_webpage" in got
|
||
assert all(not d.get("default") for d in fetched)
|
||
assert any(d.get("name") == "read_webpage" for d in fetched)
|
||
|
||
def test_default_tool_prefetched_when_template_references_it(
|
||
self, pg_conn
|
||
):
|
||
from application.agents.default_tools import default_tool_id
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
|
||
sp = StreamProcessor({}, {"sub": "u-tpl-default"})
|
||
sp._required_tool_actions = {"read_webpage": {None}}
|
||
fetched = []
|
||
|
||
def _fake_fetch(tool_doc, required_actions):
|
||
fetched.append(tool_doc)
|
||
return {"ok": True}
|
||
|
||
with _patch_db(pg_conn), patch(
|
||
"application.api.answer.services.stream_processor.settings.ENABLE_TOOL_PREFETCH",
|
||
True,
|
||
), patch.object(sp, "_fetch_tool_data", _fake_fetch):
|
||
got = sp.pre_fetch_tools()
|
||
assert got is not None
|
||
assert any(
|
||
d.get("name") == "read_webpage" and d.get("default")
|
||
for d in fetched
|
||
)
|
||
assert default_tool_id("read_webpage") in got
|
||
# No explicit row of the same name exists, so the default also
|
||
# claims the name key (what preset templates reference).
|
||
assert got.get("read_webpage") == {"ok": True}
|
||
|
||
def test_explicit_row_keeps_name_key_over_default(self, pg_conn):
|
||
from application.agents.default_tools import default_tool_id
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
from application.storage.db.repositories.user_tools import (
|
||
UserToolsRepository,
|
||
)
|
||
|
||
UserToolsRepository(pg_conn).create(
|
||
user_id="u-shadow-default", name="read_webpage", status=True
|
||
)
|
||
sp = StreamProcessor({}, {"sub": "u-shadow-default"})
|
||
sp._required_tool_actions = {"read_webpage": {None}}
|
||
|
||
def _fake_fetch(tool_doc, required_actions):
|
||
return {"is_default": bool(tool_doc.get("default"))}
|
||
|
||
with _patch_db(pg_conn), patch(
|
||
"application.api.answer.services.stream_processor.settings.ENABLE_TOOL_PREFETCH",
|
||
True,
|
||
), patch.object(sp, "_fetch_tool_data", _fake_fetch):
|
||
got = sp.pre_fetch_tools()
|
||
assert got is not None
|
||
# The explicit row owns the name key; the default stays reachable
|
||
# by its synthetic id.
|
||
assert got["read_webpage"] == {"is_default": False}
|
||
assert got[default_tool_id("read_webpage")] == {"is_default": True}
|
||
|
||
def test_fetch_tool_data_executes_referenced_memory_view(self, pg_conn):
|
||
from unittest.mock import MagicMock
|
||
|
||
from application.agents.default_tools import synthesize_default_tool
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
|
||
sp = StreamProcessor({}, {"sub": "u-mem-prefetch"})
|
||
tool_doc = synthesize_default_tool("memory")
|
||
|
||
mock_tool = MagicMock()
|
||
mock_tool.get_actions_metadata.return_value = tool_doc["actions"]
|
||
mock_tool.execute_action.return_value = "Directory: /\n(empty)"
|
||
mock_manager = MagicMock()
|
||
mock_manager.load_tool.return_value = mock_tool
|
||
|
||
with patch(
|
||
"application.agents.tools.tool_manager.ToolManager",
|
||
return_value=mock_manager,
|
||
):
|
||
got = sp._fetch_tool_data(tool_doc, {"memory_view"})
|
||
|
||
# Only the referenced action ran, with no path kwarg — the tool's
|
||
# own "/" default applies.
|
||
assert got == {"memory_view": "Directory: /\n(empty)"}
|
||
mock_tool.execute_action.assert_called_once_with("memory_view")
|
||
|
||
def test_agent_bound_invocation_omits_default_tool_prefetch(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
|
||
sp = StreamProcessor({"agent_id": "agent-xyz"}, {"sub": "u-ag"})
|
||
sp._required_tool_actions = {"read_webpage": {None}}
|
||
with _patch_db(pg_conn), patch(
|
||
"application.api.answer.services.stream_processor.settings.ENABLE_TOOL_PREFETCH",
|
||
True,
|
||
):
|
||
got = sp.pre_fetch_tools()
|
||
assert got is None
|
||
|
||
def test_template_name_key_favors_explicit_over_default(self, pg_conn):
|
||
"""An explicit row and the synthesized default of the same name
|
||
coexist: name key stays on the explicit, default reachable by
|
||
synthetic id only."""
|
||
from application.agents.default_tools import default_tool_id
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
from application.storage.db.repositories.user_tools import (
|
||
UserToolsRepository,
|
||
)
|
||
|
||
user = "u-collision"
|
||
explicit = UserToolsRepository(pg_conn).create(
|
||
user_id=user, name="read_webpage", status=True,
|
||
)
|
||
explicit_id = str(explicit["id"])
|
||
default_id = default_tool_id("read_webpage")
|
||
|
||
sp = StreamProcessor({}, {"sub": user})
|
||
sp._required_tool_actions = {"read_webpage": {None}}
|
||
|
||
def _fake_fetch(tool_doc, required_actions):
|
||
return {
|
||
"is_default": bool(tool_doc.get("default")),
|
||
"id": str(tool_doc.get("_id") or tool_doc.get("id")),
|
||
}
|
||
|
||
with _patch_db(pg_conn), patch(
|
||
"application.api.answer.services.stream_processor.settings.ENABLE_TOOL_PREFETCH",
|
||
True,
|
||
), patch.object(sp, "_fetch_tool_data", _fake_fetch):
|
||
got = sp.pre_fetch_tools()
|
||
assert got is not None
|
||
assert got["read_webpage"]["is_default"] is False
|
||
assert got["read_webpage"]["id"] == explicit_id
|
||
assert got[explicit_id]["is_default"] is False
|
||
assert got[default_id]["is_default"] is True
|
||
|
||
|
||
class TestValidateAndSetModelAgentAuthority:
|
||
"""Agent-bound chats: agent's ``default_model_id`` is authoritative."""
|
||
|
||
def test_agent_bound_ignores_body_model_id(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"model_id": "body-model"}, {"sub": "caller"})
|
||
sp._agent_data = {"user": "owner"}
|
||
sp.agent_config = {
|
||
"default_model_id": "agent-model",
|
||
"user_id": "owner",
|
||
}
|
||
captured: list = []
|
||
|
||
def _fake_validate(model_id, user_id=None):
|
||
captured.append((model_id, user_id))
|
||
return True
|
||
|
||
with patch(
|
||
"application.api.answer.services.stream_processor.validate_model_id",
|
||
side_effect=_fake_validate,
|
||
), patch(
|
||
"application.api.answer.services.stream_processor.get_default_model_id",
|
||
return_value="global-default",
|
||
):
|
||
sp._validate_and_set_model()
|
||
assert sp.model_id == "agent-model"
|
||
# Resolved under the agent owner, not the caller.
|
||
assert sp.model_user_id == "owner"
|
||
assert ("agent-model", "owner") in captured
|
||
|
||
def test_agent_bound_no_default_falls_back_to_system(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"model_id": "body-model"}, {"sub": "u"})
|
||
sp._agent_data = {"user": "u"}
|
||
sp.agent_config = {"default_model_id": "", "user_id": "u"}
|
||
with patch(
|
||
"application.api.answer.services.stream_processor.validate_model_id",
|
||
return_value=False,
|
||
), patch(
|
||
"application.api.answer.services.stream_processor.get_default_model_id",
|
||
return_value="global-default",
|
||
):
|
||
sp._validate_and_set_model()
|
||
assert sp.model_id == "global-default"
|
||
assert sp.model_user_id is None
|
||
|
||
def test_agentless_body_model_still_wins(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
sp = StreamProcessor({"model_id": "body-model"}, {"sub": "u"})
|
||
sp._agent_data = None
|
||
with patch(
|
||
"application.api.answer.services.stream_processor.validate_model_id",
|
||
return_value=True,
|
||
):
|
||
sp._validate_and_set_model()
|
||
assert sp.model_id == "body-model"
|
||
assert sp.model_user_id == "u"
|
||
|
||
|
||
class TestGetDataFromApiKeySourceUnion:
|
||
"""`_get_data_from_api_key`: primary ∪ extras, deduplicated, primary first."""
|
||
|
||
def _make_sp(self):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
return StreamProcessor({}, {"sub": "u"})
|
||
|
||
def test_union_primary_and_extras(self, pg_conn):
|
||
from application.storage.db.repositories.agents import AgentsRepository
|
||
from application.storage.db.repositories.sources import SourcesRepository
|
||
|
||
owner = "u-merge-both"
|
||
sources_repo = SourcesRepository(pg_conn)
|
||
primary = sources_repo.create(name="primary", user_id=owner)
|
||
extra1 = sources_repo.create(name="extra1", user_id=owner)
|
||
extra2 = sources_repo.create(name="extra2", user_id=owner)
|
||
|
||
agent = AgentsRepository(pg_conn).create(
|
||
owner, "agent-merge", "published",
|
||
key="merge-key",
|
||
source_id=str(primary["id"]),
|
||
extra_source_ids=[str(extra1["id"]), str(extra2["id"])],
|
||
retriever="hybrid",
|
||
chunks=5,
|
||
)
|
||
assert agent is not None
|
||
|
||
sp = self._make_sp()
|
||
with _patch_db(pg_conn):
|
||
data = sp._get_data_from_api_key("merge-key")
|
||
ids = [s["id"] for s in data["sources"]]
|
||
assert ids == [
|
||
str(primary["id"]),
|
||
str(extra1["id"]),
|
||
str(extra2["id"]),
|
||
]
|
||
assert data["source"] == str(primary["id"])
|
||
|
||
def test_only_primary(self, pg_conn):
|
||
from application.storage.db.repositories.agents import AgentsRepository
|
||
from application.storage.db.repositories.sources import SourcesRepository
|
||
|
||
owner = "u-merge-primary-only"
|
||
primary = SourcesRepository(pg_conn).create(
|
||
name="primary", user_id=owner,
|
||
)
|
||
|
||
AgentsRepository(pg_conn).create(
|
||
owner, "primary-only", "published",
|
||
key="primary-only-key",
|
||
source_id=str(primary["id"]),
|
||
extra_source_ids=[],
|
||
)
|
||
|
||
sp = self._make_sp()
|
||
with _patch_db(pg_conn):
|
||
data = sp._get_data_from_api_key("primary-only-key")
|
||
assert [s["id"] for s in data["sources"]] == [str(primary["id"])]
|
||
assert data["source"] == str(primary["id"])
|
||
|
||
def test_only_extras(self, pg_conn):
|
||
from application.storage.db.repositories.agents import AgentsRepository
|
||
from application.storage.db.repositories.sources import SourcesRepository
|
||
|
||
owner = "u-merge-extras-only"
|
||
e1 = SourcesRepository(pg_conn).create(name="e1", user_id=owner)
|
||
e2 = SourcesRepository(pg_conn).create(name="e2", user_id=owner)
|
||
|
||
AgentsRepository(pg_conn).create(
|
||
owner, "extras-only", "published",
|
||
key="extras-only-key",
|
||
extra_source_ids=[str(e1["id"]), str(e2["id"])],
|
||
)
|
||
|
||
sp = self._make_sp()
|
||
with _patch_db(pg_conn):
|
||
data = sp._get_data_from_api_key("extras-only-key")
|
||
assert [s["id"] for s in data["sources"]] == [
|
||
str(e1["id"]), str(e2["id"]),
|
||
]
|
||
assert data["source"] is None
|
||
|
||
def test_dedupe_primary_repeated_in_extras(self, pg_conn):
|
||
from application.storage.db.repositories.agents import AgentsRepository
|
||
from application.storage.db.repositories.sources import SourcesRepository
|
||
|
||
owner = "u-merge-dedupe"
|
||
primary = SourcesRepository(pg_conn).create(
|
||
name="dup-primary", user_id=owner,
|
||
)
|
||
extra = SourcesRepository(pg_conn).create(
|
||
name="dup-extra", user_id=owner,
|
||
)
|
||
|
||
AgentsRepository(pg_conn).create(
|
||
owner, "dedupe", "published",
|
||
key="dedupe-key",
|
||
source_id=str(primary["id"]),
|
||
extra_source_ids=[str(primary["id"]), str(extra["id"])],
|
||
)
|
||
|
||
sp = self._make_sp()
|
||
with _patch_db(pg_conn):
|
||
data = sp._get_data_from_api_key("dedupe-key")
|
||
ids = [s["id"] for s in data["sources"]]
|
||
assert ids == [str(primary["id"]), str(extra["id"])]
|
||
|
||
|
||
class TestAgentBoundFieldsAuthoritative:
|
||
"""End-to-end regression: agent's source/model/chunks/retriever win."""
|
||
|
||
def test_agent_values_win_over_body(self, pg_conn):
|
||
from application.api.answer.services.stream_processor import (
|
||
StreamProcessor,
|
||
)
|
||
from application.storage.db.repositories.agents import AgentsRepository
|
||
from application.storage.db.repositories.sources import SourcesRepository
|
||
|
||
owner = "u-regr-agent-authority"
|
||
primary = SourcesRepository(pg_conn).create(
|
||
name="primary", user_id=owner,
|
||
)
|
||
extra = SourcesRepository(pg_conn).create(
|
||
name="extra", user_id=owner,
|
||
)
|
||
AgentsRepository(pg_conn).create(
|
||
owner, "authoritative", "published",
|
||
key="auth-key",
|
||
source_id=str(primary["id"]),
|
||
extra_source_ids=[str(extra["id"])],
|
||
default_model_id="model-A",
|
||
retriever="hybrid",
|
||
chunks=5,
|
||
)
|
||
|
||
# Body sends different values for every field; all must be ignored.
|
||
body = {
|
||
"api_key": "auth-key",
|
||
"model_id": "body-model-Z",
|
||
"retriever": "duckdb",
|
||
"chunks": 99,
|
||
"active_docs": "body-source-id",
|
||
}
|
||
sp = StreamProcessor(body, {"sub": owner})
|
||
|
||
with _patch_db(pg_conn), patch(
|
||
"application.api.answer.services.stream_processor.validate_model_id",
|
||
return_value=True,
|
||
), patch(
|
||
"application.api.answer.services.stream_processor.get_default_model_id",
|
||
return_value="system-default",
|
||
):
|
||
sp._configure_agent()
|
||
sp._validate_and_set_model()
|
||
sp._configure_source()
|
||
sp._configure_retriever()
|
||
|
||
assert sp.model_id == "model-A"
|
||
assert sp.model_user_id == owner
|
||
assert sp.agent_config["default_model_id"] == "model-A"
|
||
assert sp.retriever_config["chunks"] == 5
|
||
assert sp.retriever_config["retriever_name"] == "hybrid"
|
||
assert sp.source == {
|
||
"active_docs": [str(primary["id"]), str(extra["id"])],
|
||
}
|