Files
privateGPT/tests/server/chat/test_chat_service.py
Javier Martinez cd8ca2214a feat: resumable chat worker + tool worker + async tokenizer (#2298)
* fix: avoid to block the loop

* fix: blocks in expansion

* fix: remove maximum concurrent users

...

* fix: multiplexer

* fix: readers

* fix: more fixes

...

* fix: impl

* feat: tool scheduler

* feat: add adaptative

* feat: add chat worker

* fix: max

* feat: add chat/tools workers

* fix: mypy

* feat: add generic scheduler

* fix: get result

* feat: do serializable the tool executor

* fix: tools

* fix: config

* fix: config

* fix: args

* fix: config

* fix: serializer

* Revert "fix: blocks in expansion"

This reverts commit a2110f94a8.

* fix: unify all logic

* feat: add ingestion scheduler

* fix: settings

* fix: config

* feat: add arq worker to chat

* fix: arq worker

* fix: add nest

* fix: mypy

* fix: await

* fix: script stress

* fix: tokenizer

* fix: chat scheduler

* fix: mypy

* fix: add async tokenizer

* fix: improve condense

* fix: tool scheduler

* feat: add initial real async chat worker

* fix: mypy

* fix: do resumable local executor

...

...

...

fix: revert usleess changes

fix: remove parent chat job

fix: refactor

fix: loop

ref: rename models

fix: chat engine

fix: mypy

...

...

...

fix: fix deps

* fix: tests

* fix: tests

* ...

* fix: stream

* fix: config

* fix: scheduler

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Handle PGPT_WORKER_MODE=celery in health check worker status

* fix: cancel

* fix: arch

* fix: test ingestion

* fix: deserialization of chat messages

* fix: broken results

* fix: mypy

* fix: test

* fix: config

* fix: remove arq tool worker

* fix: output cls

* fix: preserve early resumable tool callbacks

* fix: preserve async tool result order

* refactor: address worker PR review comments

* fix: mypy

* test: colocate ARQ chat enqueue coverage

* fix: remove redis from tests

* test: isolate chat mocks and cancellation timing

* fix: tests

(cherry picked from commit 218b599c66)

# Conflicts:
#	tests/server/chat/anthropic/test_anthropic_client.py
#	tests/server/chat/anthropic/test_langchain_anthropic.py
#	tests/server/chat/test_chat_knowledge_revamp.py
#	tests/server/chat/test_chat_routes.py
#	tests/server/chat/test_chat_routes_skills_integration.py

* fix: tests

(cherry picked from commit fc5ec0f72a)

* fix: ruff

* fix: test

* fix: worker config

(cherry picked from commit 1371c275a1)

* fix: principal

* test: remove flaky chat cancellation assertion

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-15 12:12:45 +02:00

201 lines
6.2 KiB
Python

from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock
import pytest
from llama_index.core.base.llms.types import MessageRole
from llama_index.core.llms import ChatMessage
from private_gpt.components.chat.models.chat_config_models import (
ResolvedChatRequest,
ResolvedSystemConfig,
)
from private_gpt.components.engines.chat.chat_engine_interface import (
ChatEngineExecution,
)
from private_gpt.components.engines.chat.interceptors.chat_interceptor import (
ChatRequestLoopInterceptor,
)
from private_gpt.components.engines.chat.models.chat_interceptor_context import (
ChatInterceptorContext,
)
from private_gpt.events.models import (
Event,
FatalError,
MessageOutputDelta,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
RawContentBlockStopEvent,
RawMessageDeltaEvent,
RawMessageStartEvent,
RawMessageStopEvent,
TextDelta,
Usage,
)
from private_gpt.server.chat.chat_service import ChatService, Completion, CompletionGen
from tests.fixtures.mock_injector import MockInjector
class _PromptInterceptorRequest(ChatRequestLoopInterceptor):
def __init__(self, label: str) -> None:
self._label = label
async def intercept(self, context: ChatInterceptorContext) -> None:
state = context.state.model_copy(deep=True)
current = state.input.request.system.prompt or ""
state.input.request.system.prompt = f"{current}{self._label}"
context.set_state(state)
async def _basic_event_stream() -> AsyncGenerator[Event, None]:
text_start = RawContentBlockStartEvent.from_text()
yield RawMessageStartEvent.from_defaults()
yield text_start
yield RawContentBlockDeltaEvent.from_content_block_start(
text_start,
TextDelta(text="hello"),
)
yield RawContentBlockStopEvent.from_start(text_start)
yield RawMessageDeltaEvent(
delta=MessageOutputDelta(stop_reason="end_turn"),
usage=Usage(input_tokens=10, output_tokens=20),
)
yield RawMessageStopEvent.from_defaults()
async def _fatal_event_stream() -> AsyncGenerator[Event, None]:
yield RawMessageStartEvent.from_defaults()
yield FatalError.from_exception(RuntimeError("boom"))
def _mock_engine_for(stream: AsyncGenerator[Event, None]) -> MagicMock:
mock_engine = MagicMock()
execution = ChatEngineExecution(
events=stream,
final_state_task=MagicMock(),
)
mock_engine.run = AsyncMock(return_value=execution)
return mock_engine
@pytest.mark.asyncio
async def test_chat_folds_loop_events(injector: MockInjector) -> None:
service: ChatService = injector.get(ChatService)
mock_engine = _mock_engine_for(_basic_event_stream())
service.build_engine = MagicMock(return_value=mock_engine)
request = ResolvedChatRequest(
messages=[ChatMessage(content="hi", role=MessageRole.USER)],
system=ResolvedSystemConfig(prompt="system"),
)
result = await service.chat(request)
assert isinstance(result, Completion)
assert result.response == "hello"
assert result.stop_reason == "end_turn"
assert result.usage is not None
assert result.usage.input_tokens == 10
assert result.usage.output_tokens == 20
mock_engine.run.assert_called_once()
@pytest.mark.asyncio
async def test_stream_chat_returns_loop_generator(injector: MockInjector) -> None:
service: ChatService = injector.get(ChatService)
mock_engine = _mock_engine_for(_basic_event_stream())
service.build_engine = MagicMock(return_value=mock_engine)
request = ResolvedChatRequest(
messages=[ChatMessage(content="hi", role=MessageRole.USER)],
system=ResolvedSystemConfig(prompt="system"),
)
result = await service.stream_chat(request)
assert isinstance(result, CompletionGen)
events = [event async for event in result.events]
assert events
assert any(isinstance(event, RawMessageStopEvent) for event in events)
@pytest.mark.asyncio
async def test_validate_returns_error_when_no_user_text(
injector: MockInjector,
) -> None:
service: ChatService = injector.get(ChatService)
request = ResolvedChatRequest(
messages=[ChatMessage(content="system", role=MessageRole.SYSTEM)],
system=ResolvedSystemConfig(prompt="system"),
)
result = await service.validate(request)
assert not result.valid
assert result.errors is not None
assert any(
("non-empty user text" in error) or ("No user message found" in error)
for error in result.errors
)
@pytest.mark.asyncio
async def test_chat_propagates_fatal_error_to_completion(
injector: MockInjector,
) -> None:
service: ChatService = injector.get(ChatService)
mock_engine = _mock_engine_for(_fatal_event_stream())
service.build_engine = MagicMock(return_value=mock_engine)
request = ResolvedChatRequest(
messages=[ChatMessage(content="hi", role=MessageRole.USER)],
system=ResolvedSystemConfig(prompt="system"),
)
result = await service.chat(request)
assert isinstance(result, Completion)
assert result.exception is not None
assert "boom" in str(result.exception)
@pytest.mark.asyncio
async def test_validate_runs_before_interceptors_in_order(
injector: MockInjector,
) -> None:
service: ChatService = injector.get(ChatService)
mock_chain = MagicMock()
mock_chain.request_interceptors = [
_PromptInterceptorRequest("-one"),
_PromptInterceptorRequest("-two"),
]
mock_chain.response_interceptors = []
service.chat_interceptor_service = MagicMock()
service.chat_interceptor_service.get_chain.return_value = mock_chain
request = ResolvedChatRequest(
messages=[ChatMessage(content="hello", role=MessageRole.USER)],
system=ResolvedSystemConfig(prompt="start"),
)
validated = await service.validate(request)
assert validated.valid
@pytest.mark.asyncio
async def test_validate_returns_valid_for_good_request(
injector: MockInjector,
) -> None:
service: ChatService = injector.get(ChatService)
request = ResolvedChatRequest(
messages=[ChatMessage(content="hello world", role=MessageRole.USER)],
system=ResolvedSystemConfig(prompt="system"),
)
result = await service.validate(request)
assert result.valid
assert result.errors is None