deer-flow/backend/tests/blocking_io/test_title_model_offloop.py
yetuge 6f24bc7c36
fix(title): build the title model off the Gateway event loop (#7813)
Thread title generation runs inside the agent graph (aafter_model), and it
built its configured model inline. create_chat_model can read local CLI
credentials while it is constructed (Codex auth.json, Claude Code credential
files), so the first turn of every new thread read them on the shared loop
and other requests waited for it.

Build the provider through run_assembly, the dedicated assembly pool the
one-shot helpers, skill moderation, the goal evaluator and start_run already
use. Inference still awaits on the loop, and the sync path is unchanged.

A Blockbuster regression builds the real Codex provider: on main the gate
rejects the credential read, the broad fallback swallows it, and the local
fallback title replaces the model's title; with the change the provider is
constructed on a worker thread and the model's title is kept.
2026-11-04 04:31:57 +08:00

82 lines
3.1 KiB
Python

"""Title generation must build its provider off the Gateway event loop.
Inside a graph run, ``aafter_model`` builds the configured title model inline.
``create_chat_model`` can read local CLI credentials (Codex ``auth.json``,
Claude Code credential files) while it is constructed, so every other request
on the shared loop waited for that read. The real Codex provider is built under
the strict gate; tracing and inference stay offline.
"""
import json
import threading
from types import SimpleNamespace
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from deerflow.agents.middlewares import title_middleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
from deerflow.config.app_config import AppConfig
from deerflow.models import factory
from deerflow.models.openai_codex_provider import CodexChatModel
pytestmark = pytest.mark.asyncio
_TITLE = "Deployment plan"
def _codex_config() -> AppConfig:
return AppConfig.model_validate(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": [{"name": "fixture-codex", "model": "fixture-model", "use": "deerflow.models.openai_codex_provider:CodexChatModel"}],
"title": {"model_name": "fixture-codex"},
}
)
@pytest.fixture
def codex(tmp_path, monkeypatch):
"""Real Codex construction (credential file IO included); offline tracing and inference."""
credentials = tmp_path / "auth.json"
credentials.write_text(json.dumps({"access_token": "fixture-token", "account_id": "fixture-account"}), encoding="utf-8")
monkeypatch.setenv("CODEX_AUTH_PATH", str(credentials))
monkeypatch.setattr(factory, "build_tracing_callbacks", lambda **kwargs: [])
state = SimpleNamespace(construction_thread=None, calls=[])
async def invoke(model, messages, *, config):
state.calls.append((model, threading.get_ident()))
return AIMessage(content=_TITLE)
monkeypatch.setattr(CodexChatModel, "ainvoke", invoke)
return state
async def test_title_model_is_built_off_the_event_loop(codex, monkeypatch):
middleware = TitleMiddleware(app_config=_codex_config())
real_create = title_middleware.create_chat_model
def recording_create(*args, **kwargs):
codex.construction_thread = threading.get_ident()
return real_create(*args, **kwargs)
monkeypatch.setattr(title_middleware, "create_chat_model", recording_create)
state = {
"messages": [
HumanMessage(content="Plan the eu-west-2 deployment.", id="user"),
AIMessage(content="The plan is ready.", id="assistant"),
]
}
result = await middleware._agenerate_title_result(state)
# On the loop the gate rejects the credential read, the broad fallback in
# _agenerate_title_result swallows it, and the local title replaces the
# configured model's title.
assert result == {"title": _TITLE}
assert len(codex.calls) == 1
assert codex.construction_thread is not None
assert codex.construction_thread != threading.get_ident()
# Inference itself still awaits on the loop once the provider exists.
assert codex.calls[0][1] == threading.get_ident()