deer-flow/backend/tests/blocking_io/test_goal_from_chat.py
Loveca 067c9736b6
feat(goal): add /goal from-chat for IM channels (#8200)
* feat(goal): add /goal from-chat for IM channels

First slice of #7050: add an explicit IM command that composes the active goal
from the recent conversation instead of taking the text literally.
Natural-language intent detection remains out of scope.

Gateway:
- POST /api/threads/{thread_id}/goal/from-chat synthesizes the objective
  from the thread summary and recent agreement turns (visible user/assistant
  text, clarification questions, and card answers), preserving agreed
  constraints and the stopping condition.
- Reject a busy thread before the model call; the save reuses the existing
  checkpoint-write reservation so a run that starts mid-synthesis still wins.
- Refuse to synthesize while a human-input request is unanswered, and return
  structured no_context/unclear/synthesis_failed outcomes without echoing
  model-generated reasons.

IM:
- /goal from-chat [hint] shows the saved objective, then starts exactly one
  run through the ordinary chat path; a longer read timeout covers synthesis.
- Map busy/pending/unclear/no-context/timeout outcomes to localized replies;
  a saved goal whose run cannot start is reported honestly.
- The TUI explains that from-chat is not supported yet; the web input box
  still treats it literally (documented follow-up).

Docs: README, CHANGELOG, IM channel guide, TUI guide, AGENTS, and the
acceptance plan/execution record. Tests: new route, synthesis and manager
suites, plus parser and TUI coverage.

* fix(goal): keep the from-chat confirmation visible on every channel

The saved-objective reply was published as a non-final progress update.
QQ and DingTalk card mode drop non-final outbounds before a stream exists,
and Feishu/Telegram fold the update into the run's stream, so the run
output overwrote the objective. Publish it as the turn's final message.

Redact PII on whole lines and the summary before the context budget can
split an identifier; the composed prompt is still redacted once more as
defense in depth, matching the goal evaluator's direct call.

Also correct the gateway/IM docs (from-chat never creates a checkpoint,
the busy pre-check is worker-local, and synthesis uses the deployment
default model), fix the route test clock patch, and add regression tests
for the final-outbound and truncation-boundary cases.

* fix(goal): align from-chat with the goal-route admission gates

Reject a from-chat save while a tool approval is pending, matching
PUT/DELETE /goal, and suppress subagent-batch continuations queued under
the previous goal after the save. Both invariants landed upstream while
this branch was in review; the new route now shares them.

Add route tests for both behaviors.

* docs(changelog): remove the from-chat entry to avoid conflicts

The maintainer asked to drop the changelog entry because the section conflicts with upstream changes. The feature remains documented in the goals guide and README.

* fix(goal): close three review gaps in /goal from-chat

- Enforce model-use authorization for the deployment default model before
  the synthesis call, matching the suggestions route, so a denied role
  never sends conversation context to the model.
- Save with expected_checkpoint_id pinned to the head the synthesis read;
  a write that landed during the model call is a 409 goal_changed instead
  of silently clobbering the newer goal.
- Re-check the conversation binding before starting the run: a /new during
  synthesis keeps the goal in the original thread and reports that the
  task did not start instead of running it in the switched conversation.

* fix(goal): preserve from-chat handoff and run status

Pin the saved thread through run preparation and reject a changed binding.
Read configuration and model authorization off the event loop, and report
rejected starts separately from requests whose response was lost.

Cover the handoff races and streaming/wait admission outcomes through the
real channel manager and SDK transport, plus file-backed config under the
strict blocking-I/O detector.

Co-authored-by: Loveca <44801706+Loveca@users.noreply.github.com>

* fix(goal): preserve reasoning tags in synthesis JSON

Use the existing JSON-aware reasoning stripper so literal tag text in
objective and reason values survives parsing. Cover complete and unclosed
tags while still removing model reasoning before the JSON payload.

Co-authored-by: Loveca <44801706+Loveca@users.noreply.github.com>

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
2026-11-13 14:22:58 +08:00

95 lines
4.3 KiB
Python

"""Goal synthesis reads real config and model authorization off the event loop."""
import asyncio
import json
import httpx
import pytest
from _router_auth_helpers import make_authed_test_app
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
from app.gateway import services
from app.gateway.auth.models import User
from app.gateway.routers import threads
from deerflow.config.app_config import reset_app_config
from deerflow.config.extensions_config import reset_extensions_config
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor, build_state_mutation_graph
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.runs.manager import RunManager
from deerflow.runtime.runs.store.memory import MemoryRunStore
@pytest.fixture
def file_config(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
extensions_path.write_text("{}", encoding="utf-8")
reset_app_config()
reset_extensions_config()
yield config_path
reset_app_config()
reset_extensions_config()
@pytest.mark.asyncio
@pytest.mark.parametrize("allowed", [True, False])
async def test_goal_from_chat_uses_file_config_and_model_policy(file_config, monkeypatch, allowed):
config = {
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": [
{
"name": "synthesis",
"model": "fixture",
"use": "langchain_core.language_models.fake_chat_models:FakeListChatModel",
"responses": [json.dumps({"objective": "Reconcile the invoices.", "unclear": False, "reason": ""})],
}
],
"authorization": {
"enabled": True,
"provider": {
"use": "deerflow.authz.rbac:RbacAuthorizationProvider",
"config": {"roles": {"user": {"models": {"allow": ["synthesis"] if allowed else []}}}},
},
},
}
await asyncio.to_thread(file_config.write_text, json.dumps(config), encoding="utf-8")
user = User(email="goal-test@example.com", password_hash="unused", system_role="user")
app = make_authed_test_app(user_factory=lambda: user, bind_current_user=True)
app.include_router(threads.router)
@app.middleware("http")
async def trusted_session(request, call_next):
request.state.auth_source = "session"
return await call_next(request)
app.state.checkpointer = InMemorySaver()
app.state.store = InMemoryStore()
app.state.run_event_store = MemoryRunEventStore()
app.state.run_store = MemoryRunStore()
app.state.run_manager = RunManager(store=app.state.run_store, event_store=app.state.run_event_store)
app.state.thread_store = MemoryThreadMetaStore(app.state.store)
await app.state.thread_store.create("goal-thread", user_id=str(user.id))
graph = await asyncio.to_thread(build_state_mutation_graph, "seed", "full")
accessor = CheckpointStateAccessor.bind(graph, app.state.checkpointer)
await accessor.aupdate(
{"configurable": {"thread_id": "goal-thread", "checkpoint_ns": ""}},
{"messages": [HumanMessage(content="Reconcile the invoices.")]},
as_node="seed",
)
monkeypatch.setattr(services, "resolve_agent_factory", lambda _: lambda **kwargs: graph)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://gateway.test") as client:
for _ in range(2): # Cold config load and cached-config revalidation.
response = await client.post("/api/threads/goal-thread/goal/from-chat", json={})
assert response.status_code == (200 if allowed else 403), response.text
if allowed:
assert response.json()["goal"]["objective"] == "Reconcile the invoices."
goal = await threads.read_thread_goal(app.state.checkpointer, "goal-thread")
assert (goal is not None) is allowed