deer-flow/backend/tests/test_agent_teams_plugin.py
Rei 9cb2bde557
test(ci): run bundled extension behavior suites outside the core shards (#8180)
Bundled examples keep their behavior tests in backend/tests/, so the core
backend workflow ran optional extension behavior on every pull request. Mark
those suites with pytest.mark.bundled_extension, exclude them from the core
shards and from the duration baseline, and run them in a dedicated workflow
through make test-bundled-extensions. Tests of host mechanisms stay in the
core shards even when an example package is the payload.

The marked suites import deerflow.agents, config, runtime, knowledge and
app.gateway, so the workflow triggers for backend/packages/**,
backend/app/gateway/** and the marked test modules; a backend test pins those
paths, the make target and both marker selections, and fails when a marked
module is missing from the path list. Host/extension compatibility checks — a
host API gate and authorization of contributed routes — live in
test_extension_host_contracts.py so they still fail core CI. The committed
duration baseline drops the 482 entries for the excluded suites.

Refs #8173

Co-authored-by: REI-330 <173389910+REI-330@users.noreply.github.com>
Co-authored-by: willem-bd <143703838+willem-bd@users.noreply.github.com>
Co-authored-by: Eilen6316 <136898293+Eilen6316@users.noreply.github.com>
2026-11-14 09:12:25 +08:00

2685 lines
135 KiB
Python

"""Team extension contracts exercised through the installed contribution."""
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from deerflow_extension_api import AgentRun, AgentRunError, ExtensionPrincipal
from deerflow_extension_api.plugins import ActionContext, ToolContext
from deerflow.extensions.loader import ExtensionSpec, load_extensions
pytestmark = pytest.mark.bundled_extension
def test_team_artifact_package_dependency_rejects_the_pre_artifact_api():
import tomllib
from packaging.requirements import Requirement
project = Path(__file__).resolve().parents[2] / "examples/deerflow-extension-agent-teams/pyproject.toml"
dependencies = tomllib.loads(project.read_text())["project"]["dependencies"]
requirement = next(Requirement(value) for value in dependencies if Requirement(value).name == "deerflow-extension-api")
assert "0.2.10" not in requirement.specifier
assert "0.2.11" in requirement.specifier
class Runs:
"""Controllable host: records real admissions and separate thread states."""
def __init__(self):
self.threads = {}
self.runs = {}
self.starts = []
self.denied = False
self.files = {}
self.imports = []
async def validate_artifact_source(self, *, thread_id, run_id, idempotency_key):
if self.denied:
raise AgentRunError(403, "revoked")
run = self.runs.get(run_id)
if run is None or run.thread_id != thread_id or run.status != "running" or run_id != idempotency_key:
raise AgentRunError(422, "Register files during the executing team run")
async def read_artifact(self, *, thread_id, path, expected_checkpoint_id=None):
if self.denied:
raise AgentRunError(403, "revoked")
if (thread_id, path) not in self.files:
raise AgentRunError(422, "File unavailable")
if expected_checkpoint_id is not None and (await self.get_state(thread_id=thread_id))["checkpoint_id"] != expected_checkpoint_id:
raise AgentRunError(412, "Producing checkpoint changed")
return self.files[thread_id, path]
async def import_artifact(self, *, thread_id, filename, content):
import hashlib
if self.denied:
raise AgentRunError(403, "revoked")
digest = hashlib.sha256(content).hexdigest()
path = "/mnt/user-data/uploads/" + digest[:16] + "-" + filename
self.files[thread_id, path] = content
self.imports.append((thread_id, path, content))
return {"path": path, "filename": path.rsplit("/", 1)[-1], "sha256": digest, "size": len(content)}
def for_plugin(self, namespace):
return self
async def create_thread(self, *, assistant_id, thread_id, metadata):
self.threads.setdefault(thread_id, {"assistant": assistant_id, "values": {"messages": []}, "next": [], "interrupts": []})
return thread_id
async def get_state(self, *, thread_id):
if self.denied or thread_id not in self.threads:
raise AgentRunError(403, "unavailable")
state = self.threads[thread_id]
return {**state, "checkpoint_id": str(len(state["values"]["messages"]))}
async def start(self, *, thread_id, input, idempotency_key, artifact_imports=None):
if self.denied:
raise AgentRunError(403, "revoked")
if idempotency_key in self.runs:
return self.runs[idempotency_key]
if any(r.thread_id == thread_id and r.status == "running" for r in self.runs.values()):
raise AgentRunError(409, "busy")
if artifact_imports:
import hashlib
for item in artifact_imports:
if hashlib.sha256(self.files.get((thread_id, item["path"]), b"")).hexdigest() != item["sha256"]:
raise AgentRunError(422, "Attached artifact copy changed; inspect the team artifacts")
self.starts.append((thread_id, input))
self.threads[thread_id]["values"]["messages"].extend(input["messages"])
run = AgentRun(thread_id, idempotency_key, "running")
self.runs[run.run_id] = run
return run
async def get(self, *, thread_id, run_id):
if self.denied:
raise AgentRunError(403, "revoked")
return self.runs[run_id]
async def cancel(self, *, thread_id, run_id):
self.runs[run_id] = AgentRun(thread_id, run_id, "interrupted")
async def resume(self, *, thread_id, resume, idempotency_key):
self.threads[thread_id]["next"] = []
self.threads[thread_id]["interrupts"] = []
run = AgentRun(thread_id, idempotency_key, "running")
self.runs[run.run_id] = run
return run
def finish(self, run_id, answer="Evidence checked.", *, interrupted=False):
old = self.runs[run_id]
self.runs[run_id] = AgentRun(old.thread_id, run_id, "success")
state = self.threads[old.thread_id]
state["values"]["messages"].append({"type": "ai", "content": answer})
state["next"] = ["tools"] if interrupted else []
state["interrupts"] = [{"value": "Approve?"}] if interrupted else []
@pytest.fixture
def plugin(tmp_path, monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-agent-teams"))
loaded, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_agent_teams:install", config={"enabled": True, "storage_path": str(tmp_path / "teams.sqlite")})])
assert not diagnostics
((_, declaration),) = loaded.plugins
actions = {a.name: a.handler for a in declaration.backend}
service = actions["list"].__self__
service.available = True # Explicit ticks keep lifecycle tests deterministic.
return loaded, actions, service
def context(runs, owner="alice", thread=None):
if thread:
run_id = next((r.run_id for r in getattr(runs, "runs", {}).values() if r.thread_id == thread and r.status == "running"), None)
return ToolContext(ExtensionPrincipal(owner), {"enabled": True}, thread, agent_runs=runs, run_id=run_id)
return ActionContext(ExtensionPrincipal(owner), {"enabled": True}, agent_runs=runs)
async def create(actions, runs):
return await actions["create"]({"request_id": "team-one", "name": "Release", "goal": "Check release readiness", "members": [{"name": "Research", "agent": "researcher"}, {"name": "Review", "agent": "reviewer"}]}, context(runs))
def state_payload(team, **overrides):
return {"team_id": team["id"], "request_id": "cost-plan", "kind": "plan", "title": "Cost review", "content": "Research gathers quotes; Review checks operating costs.", **overrides}
def artifact_tools(loaded):
return {tool.name: tool.handler for _, declaration in loaded.plugins for tool in declaration.tools}
async def producing_task(plugin):
loaded, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
author, reviewer = team["members"]
job = await actions["send"]({"team_id": team["id"], "member_id": author["id"], "text": "Produce the report", "request_id": "produce"}, context(runs))
await service.tick()
return loaded, actions, service, runs, team, author, reviewer, job
@pytest.mark.asyncio
async def test_team_artifact_capture_and_binary_handoff_use_the_exact_retained_version(plugin):
import hashlib
loaded, actions, service, runs, team, author, reviewer, job = await producing_task(plugin)
tools = artifact_tools(loaded)
source = "/mnt/user-data/outputs/report.pdf"
content = b"%PDF-1.7\n\x00\xff first version"
runs.files[author["thread_id"], source] = content
args = {"team_id": team["id"], "request_id": "report-v1", "name": "Release report", "path": source}
published = await tools["register_team_artifact"](args, context(runs, thread=author["thread_id"]))
entry = published["artifact"]
reference = {"artifact_id": entry["artifact_id"], "version": entry["version"]}
assert entry["status"] == "pending"
assert entry["source"]["job_id"] == job["id"]
target = await tools["send_member"]({"team_id": team["id"], "member_id": reviewer["id"], "text": "Review this exact report", "request_id": "review-report", "artifacts": [reference]}, context(runs, thread=author["thread_id"]))
await service.tick()
assert len(runs.starts) == 1
runs.finish(job["id"])
await service.tick()
retained = (await tools["list_team_artifacts"]({"team_id": team["id"]}, context(runs, thread=reviewer["thread_id"])))["artifacts"][0]
assert retained["status"] == "ready"
assert retained["sha256"] == hashlib.sha256(content).hexdigest()
assert retained["source"]["run_id"] == job["id"]
runs.files[author["thread_id"], source] = b"later edit must not replace the published version"
await service.tick()
assert any(thread == reviewer["thread_id"] for thread, _ in runs.starts)
copied_thread, copied_path, copied = next(row for row in runs.imports if row[0] == reviewer["thread_id"])
assert copied == content
request = json.loads(runs.starts[-1][1]["messages"][0]["content"].split("\n", 1)[1])
assert request["artifacts"][0]["path"] == copied_path
assert request["artifacts"][0]["sha256"] == retained["sha256"]
assert (await tools["register_team_artifact"](args, context(runs, thread=author["thread_id"])))["artifact"] == retained
result = await actions["get"]({"team_id": team["id"], "details": True}, context(runs))
assert next(j for j in result["jobs"] if j["id"] == target["id"])["artifacts"] == [reference]
assert "_request" not in json.dumps(result)
@pytest.mark.asyncio
async def test_team_artifact_versions_survive_restart_and_source_removal(plugin):
import base64
loaded, actions, service, runs, team, author, reviewer, first = await producing_task(plugin)
tools = artifact_tools(loaded)
source = "/mnt/user-data/outputs/report.txt"
runs.files[author["thread_id"], source] = b"first"
args = {"team_id": team["id"], "request_id": "v1", "name": "Report", "path": source}
old = (await tools["register_team_artifact"](args, context(runs, thread=author["thread_id"])))["artifact"]
runs.finish(first["id"])
await service.tick()
second = await actions["send"]({"team_id": team["id"], "member_id": author["id"], "text": "Revise report", "request_id": "revise"}, context(runs))
await service.tick()
runs.files[author["thread_id"], source] = b"second"
newer = (await tools["register_team_artifact"]({**args, "request_id": "v2", "artifact_id": old["artifact_id"]}, context(runs, thread=author["thread_id"])))["artifact"]
assert newer["version"] == 2
runs.finish(second["id"])
await service.tick()
runs.files.clear()
service.handles.clear()
for version, expected in ((1, b"first"), (2, b"second")):
read = await tools["read_team_artifact"]({"team_id": team["id"], "artifact_id": old["artifact_id"], "version": version}, context(None, thread=reviewer["thread_id"]))
assert base64.b64decode(read["content_base64"]) == expected
assert not service.handles
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["register_team_artifact", "list_team_artifacts", "read_team_artifact"])
@pytest.mark.parametrize("owner,thread_kind", [("mallory", "member"), ("alice", "outside"), ("alice", "missing")])
async def test_team_artifact_tools_require_owner_and_current_member(plugin, method, owner, thread_kind):
loaded, actions, service, runs, team, author, reviewer, job = await producing_task(plugin)
tools = artifact_tools(loaded)
registration = {"team_id": team["id"], "request_id": "source", "name": "Report", "path": "/mnt/user-data/outputs/report.txt"}
entry = (await tools["register_team_artifact"](registration, context(runs, thread=author["thread_id"])))["artifact"]
if method == "read_team_artifact":
runs.files[author["thread_id"], registration["path"]] = b"private snapshot"
runs.finish(job["id"])
await service.tick()
payload = {
"register_team_artifact": {**registration, "request_id": "unauthorized"},
"list_team_artifacts": {"team_id": team["id"]},
"read_team_artifact": {"team_id": team["id"], "artifact_id": entry["artifact_id"], "version": 1},
}[method]
thread = {"member": author["thread_id"], "outside": "unrelated-thread", "missing": None}[thread_kind]
before = await service.db("get", "alice", team["id"])
with pytest.raises(ValueError):
await tools[method](payload, context(runs, owner=owner, thread=thread))
assert await service.db("get", "alice", team["id"]) == before
@pytest.mark.asyncio
@pytest.mark.parametrize("path", ["/etc/passwd", "/mnt/user-data/uploads/private.txt", "/mnt/user-data/outputs/../secret.txt", "/mnt/user-data/outputs/", "/mnt/user-data/outputs/report\n.txt"])
async def test_team_artifact_registration_rejects_unsafe_paths_without_mutation(plugin, path):
loaded, actions, service, runs, team, author, reviewer, job = await producing_task(plugin)
before = await service.db("get", "alice", team["id"])
with pytest.raises(ValueError):
await artifact_tools(loaded)["register_team_artifact"]({"team_id": team["id"], "request_id": "v1", "name": "Report", "path": path}, context(runs, thread=author["thread_id"]))
assert await service.db("get", "alice", team["id"]) == before
@pytest.mark.asyncio
@pytest.mark.parametrize("end", ["cancel", "missing"])
async def test_team_artifact_unavailable_source_fails_dependent_without_admission(plugin, end):
loaded, actions, service, runs, team, author, reviewer, job = await producing_task(plugin)
tools = artifact_tools(loaded)
entry = (await tools["register_team_artifact"]({"team_id": team["id"], "request_id": "v1", "name": "Report", "path": "/mnt/user-data/outputs/report.txt"}, context(runs, thread=author["thread_id"])))["artifact"]
reference = {"artifact_id": entry["artifact_id"], "version": 1}
target = await tools["send_member"]({"team_id": team["id"], "member_id": reviewer["id"], "text": "Review", "request_id": "review", "artifacts": [reference]}, context(runs, thread=author["thread_id"]))
if end == "cancel":
await actions["cancel"]({"team_id": team["id"], "job_id": job["id"]}, context(runs))
else:
runs.finish(job["id"])
for _ in range(3):
await service.tick()
result = await actions["get"]({"team_id": team["id"], "details": True}, context(runs))
assert result["artifacts"][0]["status"] == "failed"
assert next(j for j in result["jobs"] if j["id"] == target["id"])["status"] == "failed"
assert all(thread != reviewer["thread_id"] for thread, _ in runs.starts)
@pytest.mark.asyncio
async def test_team_artifact_import_retries_after_lost_ack_with_retained_bytes(plugin, monkeypatch):
loaded, actions, service, runs, team, author, reviewer, job = await producing_task(plugin)
tools = artifact_tools(loaded)
source = "/mnt/user-data/outputs/report.txt"
runs.files[author["thread_id"], source] = b"retained"
entry = (await tools["register_team_artifact"]({"team_id": team["id"], "request_id": "v1", "name": "Report", "path": source}, context(runs, thread=author["thread_id"])))["artifact"]
runs.finish(job["id"])
await service.tick()
original_import = runs.import_artifact
lost = True
async def lose_ack(**kwargs):
nonlocal lost
result = await original_import(**kwargs)
if lost:
lost = False
raise TimeoutError("acknowledgement lost")
return result
monkeypatch.setattr(runs, "import_artifact", lose_ack)
await actions["send"]({"team_id": team["id"], "member_id": reviewer["id"], "text": "Review", "request_id": "review", "artifacts": [{"artifact_id": entry["artifact_id"], "version": 1}]}, context(runs))
await service.tick()
assert len(runs.starts) == 1
service.handles.clear()
await actions["connect"]({"team_id": team["id"]}, context(runs))
await service.tick()
assert len(runs.starts) == 2
assert runs.imports[0] == runs.imports[1]
@pytest.mark.asyncio
async def test_team_artifact_retry_conflicts_bounds_and_deletion(plugin, monkeypatch):
loaded, actions, service, runs, team, author, reviewer, job = await producing_task(plugin)
tools = artifact_tools(loaded)
args = {"team_id": team["id"], "request_id": "v1", "name": "Report", "path": "/mnt/user-data/outputs/report.txt"}
entry = (await tools["register_team_artifact"](args, context(runs, thread=author["thread_id"])))["artifact"]
with pytest.raises(ValueError, match="Request ID"):
await tools["register_team_artifact"]({**args, "path": "/mnt/user-data/outputs/other.txt"}, context(runs, thread=author["thread_id"]))
for index in range(1, 4):
await tools["register_team_artifact"]({**args, "request_id": f"extra-{index}"}, context(runs, thread=author["thread_id"]))
with pytest.raises(ValueError, match="limit"):
await tools["register_team_artifact"]({**args, "request_id": "overflow"}, context(runs, thread=author["thread_id"]))
runs.files[author["thread_id"], args["path"]] = b"saved"
runs.finish(job["id"])
await service.tick()
await actions["delete"]({"team_id": team["id"]}, context(runs))
with pytest.raises(ValueError):
await service.db("artifact", "alice", team["id"], entry["artifact_id"], 1)
@pytest.mark.asyncio
async def test_team_artifact_storage_budget_rolls_back_and_versions_are_immutable(plugin, monkeypatch):
import sqlite3
import deerflow_extension_agent_teams.artifacts as artifacts
loaded, actions, service, runs, team, author, reviewer, job = await producing_task(plugin)
monkeypatch.setattr(artifacts, "OWNER_BYTES", 5)
tools = artifact_tools(loaded)
for index in (1, 2):
path = f"/mnt/user-data/outputs/report-{index}.txt"
runs.files[author["thread_id"], path] = b"1234"
await tools["register_team_artifact"]({"team_id": team["id"], "request_id": f"file-{index}", "name": "Report", "path": path}, context(runs, thread=author["thread_id"]))
runs.finish(job["id"])
await service.tick()
entries = (await tools["list_team_artifacts"]({"team_id": team["id"]}, context(runs, thread=reviewer["thread_id"])))["artifacts"]
assert [entry["status"] for entry in entries] == ["ready", "failed"]
assert "storage limit" in entries[1]["error"]
with sqlite3.connect(service.store.path) as db:
assert db.execute("SELECT sum(length(content)) FROM team_artifact_versions").fetchone()[0] == 4
with pytest.raises(sqlite3.IntegrityError, match="cannot be overwritten"):
db.execute("UPDATE team_artifact_versions SET content=?", (b"replacement",))
@pytest.mark.asyncio
async def test_team_artifact_chunk_reads_are_bounded_and_keep_empty_and_binary_files(plugin):
import base64
loaded, actions, service, runs, team, author, reviewer, job = await producing_task(plugin)
tools = artifact_tools(loaded)
content = bytes(range(256)) * 100
for index, data in enumerate((b"", content)):
path = f"/mnt/user-data/outputs/{index}.bin"
runs.files[author["thread_id"], path] = data
await tools["register_team_artifact"]({"team_id": team["id"], "request_id": str(index), "name": '"\\\n' * 25, "path": path}, context(runs, thread=author["thread_id"]))
runs.finish(job["id"])
await service.tick()
entries = await tools["list_team_artifacts"]({"team_id": team["id"], "limit": 1}, context(runs, thread=reviewer["thread_id"]))
assert entries["next_offset"] == 1
second = await tools["list_team_artifacts"]({"team_id": team["id"], "offset": 1}, context(runs, thread=reviewer["thread_id"]))
for entry, expected in ((entries["artifacts"][0], b""), (second["artifacts"][0], content)):
data = b""
offset = 0
while offset is not None:
page = await tools["read_team_artifact"]({"team_id": team["id"], "artifact_id": entry["artifact_id"], "version": 1, "offset": offset}, context(None, thread=reviewer["thread_id"]))
assert len(json.dumps(page, ensure_ascii=False).encode()) <= 24000
data += base64.b64decode(page["content_base64"])
offset = page["next_offset"]
assert data == expected
@pytest.mark.asyncio
async def test_team_artifact_registration_and_retrieval_use_native_tool_node(plugin):
from deerflow_extension_api.agent_runs import AGENT_RUNS_CONTEXT_KEY
from langchain_core.messages import AIMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from deerflow.extensions.plugin_tools import build_plugin_tools
loaded, actions, service, runs, team, author, reviewer, job = await producing_task(plugin)
tools = [tool for tool in build_plugin_tools(loaded) if any(name in tool.name for name in ("register_team_artifact", "list_team_artifacts", "read_team_artifact"))]
assert len(tools) == 3
graph = StateGraph(MessagesState)
graph.add_node("tools", ToolNode(tools))
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
compiled = graph.compile()
async def invoke(name, payload, thread):
tool = next(tool for tool in tools if name in tool.name)
execution = {"thread_id": thread, "run_id": context(runs, thread=thread).run_id, "user_id": "alice", AGENT_RUNS_CONTEXT_KEY: runs}
result = await compiled.ainvoke({"messages": [AIMessage(content="", tool_calls=[{"name": tool.name, "args": payload, "id": name}])]}, context=execution)
return next(message for message in result["messages"] if message.type == "tool")
path = "/mnt/user-data/outputs/report.txt"
runs.files[author["thread_id"], path] = b"native file evidence"
publication = await invoke("register_team_artifact", {"team_id": team["id"], "request_id": "native-file", "name": "Report", "path": path}, author["thread_id"])
assert json.loads(publication.content)["artifact"]["status"] == "pending"
runs.finish(job["id"])
await service.tick()
listing = await invoke("list_team_artifacts", {"team_id": team["id"]}, reviewer["thread_id"])
entry = json.loads(listing.content)["artifacts"][0]
answer = await invoke("read_team_artifact", {"team_id": team["id"], "artifact_id": entry["artifact_id"], "version": 1}, reviewer["thread_id"])
assert json.loads(answer.content)["content_base64"] == "bmF0aXZlIGZpbGUgZXZpZGVuY2U="
denied = await invoke("read_team_artifact", {"team_id": team["id"], "artifact_id": entry["artifact_id"], "version": 1}, "outside")
assert "bmF0aXZlIGZpbGUgZXZpZGVuY2U=" not in denied.content
def state_update(team, entry, **overrides):
return {"team_id": team["id"], "entry_id": entry["id"], "expected_revision": entry["revision"], "title": entry["title"], "content": "Include the annual operating costs.", "status": "active", **overrides}
@pytest.mark.asyncio
async def test_public_state_is_shared_persistent_and_independent_of_recent_history(plugin):
from deerflow_extension_agent_teams.service import Teams
loaded, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
author, reader = team["members"]
tools = {tool.name: tool.handler for _, p in loaded.plugins for tool in p.tools}
published = await tools["publish_team_state"](state_payload(team), context(runs, thread=author["thread_id"]))
entry = published["entry"]
assert entry["revision"] == published["revision"] == 1
assert entry["created_by"] == entry["updated_by"] == author["id"]
assert entry["source"] == {"thread_id": author["thread_id"], "job_id": None}
assert entry["created_at"] == entry["updated_at"]
assert (await tools["read_team_state"]({"team_id": team["id"]}, context(runs, thread=reader["thread_id"])))["entries"] == [entry]
# Rotate all shared history out. Public state must still reach a real admission.
await service.db("change", "alice", team["id"], lambda t: t.update(messages=[]))
await actions["send"]({"team_id": team["id"], "member_id": reader["id"], "text": "Check the plan", "request_id": "check-state"}, context(runs))
await service.tick()
message = runs.starts[-1][1]["messages"][0]
assert message["role"] == "user"
supplied = json.loads(message["content"].split("\n", 1)[1])
assert supplied["public_state"]["entries"] == [entry]
assert supplied["goal"] == team["goal"]
reopened = Teams(service.store.path)
before = service.store.get("alice", team["id"])
assert await reopened.read_state({"team_id": team["id"]}, context(None, thread=reader["thread_id"])) == {"team_id": team["id"], "revision": 1, "entries": [entry]}
assert service.store.get("alice", team["id"]) == before
assert not reopened.handles
assert (await actions["get"]({"team_id": team["id"]}, context(runs)))["public_state"]["entries"] == [entry]
@pytest.mark.asyncio
async def test_public_state_updates_are_atomic_attributed_and_replay_safe(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
author, reviewer = team["members"]
a, b = (context(runs, thread=m["thread_id"]) for m in (author, reviewer))
published = await service.publish_state(state_payload(team), a)
entry = published["entry"]
requests = [state_update(team, entry, content=value) for value in ("Quote A", "Quote B")]
results = await asyncio.gather(*(service.update_state(payload, b) for payload in requests))
assert sorted(r["updated"] for r in results) == [False, True]
winner = next(r for r in results if r["updated"])
conflict = next(r for r in results if not r["updated"])
assert conflict["conflict"] is True and conflict["entry"] == winner["entry"]
updated = winner["entry"]
assert updated["revision"] == 2 and updated["created_by"] == author["id"] and updated["updated_by"] == reviewer["id"]
replay = await service.update_state(next(p for p in requests if p["content"] == updated["content"]), b)
assert replay["updated"] is True and replay["entry"] == updated and replay["revision"] == 2
# Publication retries remain deduplicated after someone updates the entry.
assert (await service.publish_state(state_payload(team), a))["entry"] == updated
assert len((await service.read_state({"team_id": team["id"]}, b))["entries"]) == 1
with pytest.raises(ValueError):
await service.publish_state(state_payload(team, content="Different original publication"), a)
archived = await service.update_state(state_update(team, updated, content=updated["content"], status="archived"), b)
assert archived["entry"]["revision"] == 3
assert not (await service.read_state({"team_id": team["id"]}, a))["entries"]
assert not (await service.read_context({"team_id": team["id"]}, a))["public_state"]["entries"]
assert (await service.read_state({"team_id": team["id"], "include_archived": True}, a))["entries"] == [archived["entry"]]
with pytest.raises(ValueError):
await service.update_state(state_update(team, updated, entry_id="missing"), b)
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["publish_state", "read_state", "update_state"])
@pytest.mark.parametrize("owner,thread", [("bob", "member"), ("alice", "outside"), ("alice", None)])
async def test_public_state_requires_owner_and_member_thread_on_every_operation(plugin, method, owner, thread):
from deerflow_extension_api.plugins import ToolContext
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
published = await service.publish_state(state_payload(team), context(runs, thread=member["thread_id"]))
payload = {"publish_state": state_payload(team, request_id="new"), "read_state": {"team_id": team["id"]}, "update_state": state_update(team, published["entry"])}[method]
before = service.store.get("alice", team["id"])
with pytest.raises(ValueError):
await getattr(service, method)(payload, ToolContext(ExtensionPrincipal(owner), {}, member["thread_id"] if thread == "member" else thread))
assert service.store.get("alice", team["id"]) == before
@pytest.mark.asyncio
async def test_public_state_queries_and_entry_sources_do_not_expose_internal_data(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
caller = context(runs, thread=member["thread_id"])
job = await actions["send"]({"team_id": team["id"], "member_id": member["id"], "text": "Find quotes", "request_id": "source"}, context(runs))
await service.tick()
first = (await service.publish_state(state_payload(team, kind="finding"), caller))["entry"]
assert first["source"]["job_id"] == job["id"]
second = (await service.publish_state(state_payload(team, request_id="question", kind="question", title="Unquoted maintenance", content="Need OPERATING quote"), caller))["entry"]
assert (await service.read_state({"team_id": team["id"], "kind": "question", "query": "operating"}, caller))["entries"] == [second]
assert (await service.read_state({"team_id": team["id"], "entry_id": first["id"]}, caller))["entries"] == [first]
assert not (await service.read_state({"team_id": team["id"], "query": "absent"}, caller))["entries"]
stored = service.store.get("alice", team["id"])
assert "_publish" in json.dumps(stored)
public = json.dumps([await service.read_context({"team_id": team["id"]}, caller), await service.read_state({"team_id": team["id"]}, caller), await service.get({"team_id": team["id"]}, context(runs))])
assert "_publish" not in public and "_update" not in public
@pytest.mark.asyncio
@pytest.mark.parametrize("extra", [{"kind": "unknown"}, {"content": ""}, {"title": "T" * 81}, {"content": "😀" * 501}, {"content": "\ud800"}, {"created_by": "spoof"}, {"source": {"thread_id": "foreign"}}])
async def test_public_state_rejects_invalid_publications_without_mutation(plugin, extra):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
before = service.store.get("alice", team["id"])
with pytest.raises(ValueError):
await service.publish_state(state_payload(team, **extra), context(runs, thread=team["members"][0]["thread_id"]))
assert service.store.get("alice", team["id"]) == before
@pytest.mark.asyncio
async def test_public_state_bounds_whole_json_and_combined_context_including_escaping(plugin):
from deerflow_extension_agent_teams.service import shared
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
caller = context(runs, thread=team["members"][0]["thread_id"])
count = 0
for i in range(20):
before = service.store.get("alice", team["id"])
try:
await service.publish_state(state_payload(team, request_id=str(i), content='\\"' * 500), caller)
except ValueError:
assert service.store.get("alice", team["id"]) == before
break
count += 1
assert 0 < count < 20
result = await service.read_state({"team_id": team["id"]}, caller)
assert len(json.dumps(result, ensure_ascii=False).encode()) <= 16000
rejected_update = False
for entry in result["entries"]:
before = service.store.get("alice", team["id"])
try:
await service.update_state(state_update(team, entry, content="\0" * 1000), caller)
except ValueError:
assert service.store.get("alice", team["id"]) == before
rejected_update = True
break
assert rejected_update
result = await service.read_state({"team_id": team["id"]}, caller)
await service.db("change", "alice", team["id"], lambda t: t.update(messages=[{"job_id": str(i), "member_id": None, "kind": "completed", "text": '"\\' * 3000} for i in range(8)]))
combined = shared(service.store.get("alice", team["id"]))
assert combined["public_state"]["entries"] == result["entries"]
assert len(combined["messages"]) < 8 and len(json.dumps(combined, ensure_ascii=False).encode()) <= 24000
# Small entries hit the independent entry-count limit, not the byte budget.
other = await actions["create"]({"request_id": "count-cap", "name": "Count", "goal": "Count state", "members": [{"name": "A", "agent": "researcher"}, {"name": "B", "agent": "reviewer"}]}, context(runs))
other_caller = context(runs, thread=other["members"][0]["thread_id"])
for i in range(20):
await service.publish_state(state_payload(other, request_id=str(i), title="P", content="x"), other_caller)
with pytest.raises(ValueError):
await service.publish_state(state_payload(other, request_id="overflow", content="x"), other_caller)
@pytest.mark.asyncio
async def test_public_state_respects_combined_context_budget_for_large_goal_and_roster(plugin):
_, actions, service = plugin
runs = Runs()
team = await actions["create"]({"request_id": "escaped-envelope", "name": "Escaped", "goal": "\0" * 2000, "members": [{"name": str(i) + "\0" * 79, "agent": "\0" * 128} for i in range(8)]}, context(runs))
caller = context(runs, thread=team["members"][0]["thread_id"])
before = service.store.get("alice", team["id"])
assert len(json.dumps(await service.read_context({"team_id": team["id"]}, caller), ensure_ascii=False).encode()) <= 24000
with pytest.raises(ValueError):
await service.publish_state(state_payload(team, content="\0" * 1000), caller)
assert service.store.get("alice", team["id"]) == before
@pytest.mark.asyncio
async def test_public_state_is_refreshed_for_new_turns_but_never_rewrites_frozen_input(plugin, monkeypatch):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
author, reader = team["members"]
caller = context(runs, thread=author["thread_id"])
entry = (await service.publish_state(state_payload(team), caller))["entry"]
payload = {"team_id": team["id"], "member_id": reader["id"], "text": "Check the plan", "request_id": "frozen-state"}
await actions["send"](payload, context(runs))
real_start = runs.start
async def lost_ack(**kwargs):
await real_start(**kwargs)
raise TimeoutError
monkeypatch.setattr(runs, "start", lost_ack)
await service.tick()
first_input = json.dumps(runs.starts[0][1])
await service.update_state(state_update(team, entry, content="The updated plan"), caller)
monkeypatch.setattr(runs, "start", real_start)
await service.tick()
assert len(runs.starts) == 1 and json.dumps(runs.starts[0][1]) == first_input
assert (await service.read_context({"team_id": team["id"]}, caller))["public_state"]["entries"][0]["content"] == "The updated plan"
# The completed request's later receipt is prepared with fresh state.
peer = await service.send({**payload, "request_id": "peer-state"}, caller)
runs.finish(next(iter(runs.runs)))
await service.tick()
await service.tick()
runs.finish(peer["id"])
await service.tick()
await service.tick()
receipt_input = runs.starts[-1][1]["messages"][0]["content"]
assert runs.starts[-1][0] == author["thread_id"]
assert json.loads(receipt_input.split("\n", 1)[1])["public_state"]["entries"][0]["content"] == "The updated plan"
@pytest.mark.asyncio
@pytest.mark.parametrize("extra", [{"expected_revision": True}, {"expected_revision": 0}, {"status": "verified"}, {"title": ""}, {"content": "😀" * 501}, {"content": "\ud800"}, {"kind": "finding"}, {"updated_by": "spoof"}])
async def test_public_state_rejects_invalid_updates_without_mutation(plugin, extra):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
caller = context(runs, thread=team["members"][0]["thread_id"])
entry = (await service.publish_state(state_payload(team), caller))["entry"]
before = service.store.get("alice", team["id"])
with pytest.raises(ValueError):
await service.update_state(state_update(team, entry, **extra), caller)
assert service.store.get("alice", team["id"]) == before
@pytest.mark.asyncio
@pytest.mark.parametrize("extra", [{"include_archived": 1}, {"kind": "unknown"}, {"query": ""}, {"owner": "bob"}])
async def test_public_state_rejects_invalid_reads_without_mutation(plugin, extra):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
before = service.store.get("alice", team["id"])
with pytest.raises(ValueError):
await service.read_state({"team_id": team["id"], **extra}, context(runs, thread=team["members"][0]["thread_id"]))
assert service.store.get("alice", team["id"]) == before
@pytest.mark.asyncio
async def test_public_state_real_tool_node_publication_query_and_revision_update(plugin):
from deerflow_extension_api.agent_runs import AGENT_RUNS_CONTEXT_KEY
from langchain_core.messages import AIMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from deerflow.extensions.plugin_tools import build_plugin_tools
loaded, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
author, reviewer = team["members"]
tools = [tool for tool in build_plugin_tools(loaded) if any(name in tool.name for name in ("publish_team_state", "read_team_state", "update_team_state"))]
assert len(tools) == 3
graph = StateGraph(MessagesState)
graph.add_node("tools", ToolNode(tools))
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
compiled = graph.compile()
async def invoke(name, payload, thread):
tool = next(tool for tool in tools if name in tool.name)
result = await compiled.ainvoke(
{"messages": [AIMessage(content="", tool_calls=[{"name": tool.name, "args": payload, "id": "state-call"}])]},
context={"thread_id": thread, "user_id": "alice", AGENT_RUNS_CONTEXT_KEY: runs},
)
return next(message for message in result["messages"] if message.type == "tool")
publication = await invoke("publish_team_state", state_payload(team, kind="objective", content="核验成本,保留来源。"), author["thread_id"])
entry = json.loads(publication.content)["entry"]
queried = await invoke("read_team_state", {"team_id": team["id"], "entry_id": entry["id"]}, reviewer["thread_id"])
assert json.loads(queried.content)["entries"] == [entry]
updated = await invoke("update_team_state", state_update(team, entry), reviewer["thread_id"])
assert json.loads(updated.content)["entry"]["updated_by"] == reviewer["id"]
denied = await invoke("publish_team_state", state_payload(team, request_id="outside"), "outside")
assert denied.status == "error"
assert len((await service.read_state({"team_id": team["id"]}, context(runs, thread=author["thread_id"])))["entries"]) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"team_name,member_name,expected", [("Release", "Research", "Release / Research"), ("T" * 80, "M" * 37, "T" * 80 + " / " + "M" * 37), ("T" * 80, "M" * 38, "M" * 38), ("T" * 80, "M" * 80, "M" * 80), ("😀" * 40, "😀" * 40, "😀" * 40)]
)
async def test_native_mention_labels_fit_host_limit_without_changing_routing(plugin, team_name, member_name, expected):
_, actions, service = plugin
runs = Runs()
team = await actions["create"]({"request_id": "long-name", "name": team_name, "goal": "Check mentions", "members": [{"name": member_name, "agent": "researcher"}, {"name": "Review", "agent": "reviewer"}]}, context(runs))
# Search still matches the full team/member names, even if the label falls back.
candidates = (await service.search({"query": team_name}, context(runs)))["items"]
assert len(candidates) == 2
assert candidates[0]["label"] == expected
assert all(len(item["label"].encode("utf-16-le")) // 2 <= 120 for item in candidates)
assert candidates[0]["id"] == f"{team['id']}/{team['members'][0]['id']}"
@pytest.mark.asyncio
async def test_members_are_full_agents_and_requests_are_deduplicated(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
assert len(runs.threads) == 2
assert {t["assistant"] for t in runs.threads.values()} == {"researcher", "reviewer"}
assert (await create(actions, runs))["id"] == team["id"]
assert (await actions["list"]({}, context(runs, "bob")))["teams"] == []
payload = {"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "Check release", "request_id": "one"}
with pytest.raises(ValueError):
await actions["send"](payload, context(runs, "bob"))
jobs = await asyncio.gather(*(actions["send"](payload, context(runs)) for _ in range(4)))
assert len({j["id"] for j in jobs}) == 1
await service.tick()
assert len(runs.starts) == 1
runs.finish(jobs[0]["id"])
await service.tick()
view = await actions["get"]({"team_id": team["id"]}, context(runs))
assert view["jobs"][0]["status"] == "completed"
assert view["messages"][-1]["text"] == "Evidence checked."
assert view["messages"][-1]["member_id"] == payload["member_id"]
@pytest.mark.asyncio
async def test_serial_admission_parallel_members_and_explicit_approval(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
jobs = []
for n, member in enumerate([team["members"][0], team["members"][0], team["members"][1]]):
jobs.append(await actions["send"]({"team_id": team["id"], "member_id": member["id"], "text": "Check", "request_id": str(n)}, context(runs)))
await service.tick()
assert len(runs.starts) == 2
runs.finish(jobs[0]["id"], interrupted=True)
await service.tick()
view = await actions["get"]({"team_id": team["id"]}, context(runs))
assert view["jobs"][0]["status"] == "waiting_input"
await service.tick()
assert len(runs.starts) == 2
await actions["resume"]({"team_id": team["id"], "job_id": jobs[0]["id"], "response": {"approved": False}, "request_id": "answer"}, context(runs))
await service.tick()
view = await actions["get"]({"team_id": team["id"]}, context(runs))
runs.finish(view["jobs"][0]["run_id"])
await service.tick()
await service.tick()
assert len(runs.starts) == 3
@pytest.mark.asyncio
async def test_peer_handoff_delivers_result_and_cannot_impersonate_member(plugin):
loaded, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
sender, recipient = team["members"]
tools = {tool.name: tool.handler for _, p in loaded.plugins for tool in p.tools}
sent = await tools["send_member"]({"team_id": team["id"], "member_id": recipient["id"], "text": "Review this finding", "request_id": "review"}, context(runs, thread=sender["thread_id"]))
await service.tick()
runs.finish(sent["id"], "Verified finding")
await service.tick()
await service.tick()
assert len(runs.starts) == 2
assert runs.starts[-1][0] == sender["thread_id"]
message = runs.starts[-1][1]["messages"][0]
assert message["role"] == "user"
assert "Verified finding" in message["content"]
assert recipient["id"] in message["content"]
with pytest.raises(ValueError):
await tools["send_member"]({"team_id": team["id"], "member_id": recipient["id"], "text": "Spoof", "request_id": "bad"}, context(runs, thread="unrelated"))
@pytest.mark.asyncio
async def test_native_mentions_route_once_and_reject_foreign_access(plugin):
from deerflow_extension_api.agent_runs import AGENT_RUNS_CONTEXT_KEY
from langchain_core.messages import HumanMessage
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
await runs.create_thread(assistant_id="lead_agent", thread_id="source", metadata={})
member = team["members"][0]
message = HumanMessage(id="m", content="@Research check this", additional_kwargs={"extension_mentions": [{"namespace": "community.agent-teams", "provider": "members", "id": f"{team['id']}/{member['id']}", "label": "ignored"}]})
runtime = SimpleNamespace(context={"thread_id": "source", "user_id": "alice", AGENT_RUNS_CONTEXT_KEY: runs})
await service.mentions([message], runtime)
await service.mentions([message], runtime)
await service.tick()
assert len(runs.starts) == 1
runs.denied = True
with pytest.raises(AgentRunError):
await service.mentions([message.model_copy(update={"id": "stolen"})], runtime)
@pytest.mark.asyncio
async def test_restart_requires_reconnect_and_preserves_existing_run(plugin):
from deerflow_extension_agent_teams.service import Teams
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
job = await actions["send"]({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "Check", "request_id": "job"}, context(runs))
await service.tick()
replacement = Teams(service.store.path)
replacement.available = True
await replacement.tick()
assert len(runs.starts) == 1
runs.finish(job["id"])
await replacement.connect({"team_id": team["id"]}, context(runs))
await replacement.tick()
view = await replacement.get({"team_id": team["id"]}, context(runs))
assert view["jobs"][0]["status"] == "completed"
assert len(runs.starts) == 1
@pytest.mark.asyncio
async def test_revocation_disconnects_without_replacing_an_active_run(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
await actions["send"]({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "Check", "request_id": "one"}, context(runs))
await service.tick()
runs.denied = True
await service.tick()
view = await actions["get"]({"team_id": team["id"]}, context(runs))
assert view["connected"] is False
assert view["jobs"][0]["status"] == "running"
assert len(runs.starts) == 1
@pytest.mark.asyncio
async def test_ambiguous_admission_retries_same_key_and_frozen_input(plugin, monkeypatch):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
original = runs.start
calls = []
async def uncertain(**kwargs):
calls.append(kwargs)
result = await original(**kwargs)
if len(calls) == 1:
raise TimeoutError
return result
monkeypatch.setattr(runs, "start", uncertain)
await actions["send"]({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "Check", "request_id": "one"}, context(runs))
await service.tick()
await service.tick()
assert calls[0] == calls[1]
assert len(runs.starts) == 1
async def queue_active_budget_jobs(actions, runs):
jobs = []
for index in range(5):
owner = "bob" if index == 4 else "alice"
team = await actions["create"](
{"request_id": f"team-{index}", "name": "Release", "goal": "Check readiness", "members": [{"name": "Research", "agent": "researcher"}, {"name": "Review", "agent": "reviewer"}]},
context(runs, owner),
)
for member in team["members"]:
if len(jobs) == 9:
break
payload = {"team_id": team["id"], "member_id": member["id"], "text": "Check", "request_id": f"job-{len(jobs)}"}
sent = await actions["send"](payload, context(runs, owner))
jobs.append({**sent, "owner": owner, "team_id": team["id"], "member_id": member["id"], "thread_id": member["thread_id"]})
return jobs
@pytest.mark.asyncio
@pytest.mark.parametrize("wait_kind", ["interrupt", "clarification"])
async def test_unadmitted_conversation_waits_do_not_consume_other_teams_active_budget(plugin, wait_kind):
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
_, actions, service = plugin
runs = Runs()
jobs = await queue_active_budget_jobs(actions, runs)
for job in jobs[:8]:
state = runs.threads[job["thread_id"]]
if wait_kind == "interrupt":
state.update(next=["tools"], interrupts=[{"value": "Approve?"}])
else:
request = SimpleNamespace(tool_call={"name": "ask_clarification", "id": "outside", "args": {"question": "Which environment?", "clarification_type": "missing_info"}}, runtime=None)
command = ClarificationMiddleware().wrap_tool_call(request, lambda _: pytest.fail("tool handler should be intercepted"))
state["values"]["messages"].extend(m.model_dump() for m in command.update["messages"])
# A later request on the same waiting conversation must still remain queued.
blocked = jobs[0]
await actions["send"]({"team_id": blocked["team_id"], "member_id": blocked["member_id"], "text": "Later", "request_id": "later"}, context(runs))
for _ in range(2):
await service.tick()
assert [thread for thread, _ in runs.starts] == [jobs[-1]["thread_id"]]
for job in jobs[:8]:
stored = await service.db("get", job["owner"], job["team_id"])
assert all(item["status"] == "queued" and item["input"] is None for item in stored["jobs"])
healthy = await actions["get"]({"team_id": jobs[-1]["team_id"]}, context(runs, "bob"))
assert healthy["jobs"][0]["status"] == "running"
@pytest.mark.asyncio
@pytest.mark.parametrize("lost_ack", [False, True])
async def test_active_budget_retains_real_and_ambiguous_admissions(plugin, monkeypatch, lost_ack):
_, actions, service = plugin
runs = Runs()
jobs = await queue_active_budget_jobs(actions, runs)
if lost_ack:
start = runs.start
async def uncertain(**kwargs):
await start(**kwargs)
raise TimeoutError("admitted but acknowledgement lost")
monkeypatch.setattr(runs, "start", uncertain)
first_admissions = None
for _ in range(2):
await service.tick()
assert len(runs.starts) == 8
if first_admissions is None:
first_admissions = set(runs.runs)
assert set(runs.runs) == first_admissions
for job in jobs:
stored = await service.db("get", job["owner"], job["team_id"])
saved = next(item for item in stored["jobs"] if item["id"] == job["id"])
assert (saved["input"] is not None) == (job["id"] in first_admissions)
assert jobs[-1]["id"] in first_admissions # Bob receives a fair admission.
@pytest.mark.asyncio
async def test_cancel_stops_a_running_request_and_pending_delete_is_rejected(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
job = await actions["send"]({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "Check", "request_id": "one"}, context(runs))
with pytest.raises(ValueError):
await actions["delete"]({"team_id": team["id"]}, context(runs))
await service.tick()
await actions["cancel"]({"team_id": team["id"], "job_id": job["id"]}, context(runs))
await service.tick()
await service.tick()
view = await actions["get"]({"team_id": team["id"]}, context(runs))
assert view["jobs"][0]["status"] == "cancelled"
await actions["delete"]({"team_id": team["id"]}, context(runs))
assert (await actions["list"]({}, context(runs)))["teams"] == []
assert len(runs.threads) == 2 # No destructive thread API is called.
@pytest.mark.asyncio
async def test_externally_advanced_state_is_never_misattributed(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
job = await actions["send"]({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "Check", "request_id": "one"}, context(runs))
await service.tick()
runs.finish(job["id"])
runs.threads[team["members"][0]["thread_id"]]["values"]["messages"].extend([{"role": "user", "content": "Another task"}, {"role": "assistant", "content": "Unrelated result"}])
await service.tick()
view = await actions["get"]({"team_id": team["id"]}, context(runs))
assert view["jobs"][0]["status"] == "failed"
assert "Unrelated result" not in str(view["messages"])
@pytest.mark.asyncio
@pytest.mark.parametrize("verified", [False, True])
async def test_same_run_steer_requires_host_input_record_before_result_attribution(plugin, verified):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
job = await actions["send"]({"team_id": team["id"], "member_id": member["id"], "text": "Check", "request_id": "steer"}, context(runs))
await service.tick()
messages = runs.threads[member["thread_id"]]["values"]["messages"]
messages.append({"type": "human", "id": "steered__user", "content": "Include the appendix", "additional_kwargs": {"run_id": job["id"], "run_input_id": "direct"}})
async def list_inputs(**kwargs):
return {"items": [{"id": "direct", "message_id": "steered", "mode": "steer", "status": "applied", "target_run_id": job["id"]}] if verified else []}
runs.list_inputs = list_inputs
runs.finish(job["id"], "Appendix checked.")
await service.tick()
view = await actions["get"]({"team_id": team["id"]}, context(runs))
assert view["jobs"][0]["status"] == ("completed" if verified else "failed")
@pytest.mark.asyncio
async def test_service_lock_prevents_two_workers_and_releases_on_shutdown(plugin):
import sqlite3
from deerflow_extension_agent_teams.service import Teams
_, _, first = plugin
second = Teams(first.store.path)
await first.start(None)
try:
with pytest.raises(sqlite3.OperationalError):
await second.start(None)
assert not second.available
finally:
await first.stop()
await second.start(None)
await second.stop()
@pytest.mark.asyncio
async def test_real_tool_node_enforces_team_owner_and_current_thread(plugin):
from deerflow_extension_api.agent_runs import AGENT_RUNS_CONTEXT_KEY
from langchain_core.messages import AIMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from deerflow.extensions.plugin_tools import build_plugin_tools
loaded, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
tool = next(t for t in build_plugin_tools(loaded) if "send_member" in t.name)
graph = StateGraph(MessagesState)
graph.add_node("tools", ToolNode([tool]))
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
args = {"team_id": team["id"], "member_id": team["members"][1]["id"], "text": "Review", "request_id": "from-model"}
for owner, expected in [("bob", False), ("alice", True)]:
result = await graph.compile().ainvoke(
{"messages": [AIMessage(content="", tool_calls=[{"name": tool.name, "args": args, "id": "dispatch"}])]}, context={"thread_id": team["members"][0]["thread_id"], "user_id": owner, AGENT_RUNS_CONTEXT_KEY: runs}
)
assert ("queued" in result["messages"][-1].content) is expected
await service.tick()
assert len(runs.starts) == 1
@pytest.mark.asyncio
async def test_mention_middleware_runs_in_real_lead_pipeline(plugin, monkeypatch, tmp_path):
from deerflow_extension_api import EXTENSION_TASK_STORE_KEY, ExtensionData
from deerflow_extension_api.agent_runs import AGENT_RUNS_CONTEXT_KEY
from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_core.messages import HumanMessage
from deerflow.agents.lead_agent.agent import build_middlewares
from deerflow.agents.thread_state import ThreadState
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import Paths
from deerflow.config.sandbox_config import SandboxConfig
loaded, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
await runs.create_thread(assistant_id="lead_agent", thread_id="source", metadata={})
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", Paths(str(tmp_path)))
config = AppConfig(sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"))
config.title.enabled = config.memory.enabled = config.summarization.enabled = False
stack = build_middlewares(config={"configurable": {}}, model_name="offline", app_config=config, extensions=loaded, memory_enabled=False, owns_agent_skill_projection=False)
graph = create_agent(FakeListChatModel(responses=["Request sent."]), middleware=stack, state_schema=ThreadState)
member = team["members"][0]
message = HumanMessage(id="native", content="@Research check release", additional_kwargs={"extension_mentions": [{"namespace": "community.agent-teams", "provider": "members", "id": f"{team['id']}/{member['id']}", "label": "Research"}]})
result = await graph.ainvoke(
{"messages": [message]}, config={"configurable": {"thread_id": "source"}}, context={"thread_id": "source", "user_id": "alice", AGENT_RUNS_CONTEXT_KEY: runs, EXTENSION_TASK_STORE_KEY: ExtensionData("native-run")}
)
assert any("requests queued" in str(m.content) for m in result["messages"])
await service.tick()
assert len(runs.starts) == 1
@pytest.mark.asyncio
async def test_fresh_thread_404_is_not_a_failed_first_request(plugin, monkeypatch):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
original = runs.get_state
async def state(**kwargs):
result = await original(**kwargs)
if not result["values"]["messages"]:
raise AgentRunError(404, "No checkpoint")
return result
monkeypatch.setattr(runs, "get_state", state)
await actions["connect"]({"team_id": team["id"]}, context(runs))
job = await actions["send"]({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "First task", "request_id": "one"}, context(runs))
await service.tick()
runs.finish(job["id"])
await service.tick()
view = await actions["get"]({"team_id": team["id"]}, context(runs))
assert view["jobs"][0]["status"] == "completed"
@pytest.mark.asyncio
async def test_host_dynamic_context_id_rewrite_preserves_result_attribution(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
job = await actions["send"]({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "First task", "request_id": "one"}, context(runs))
await service.tick()
messages = runs.threads[team["members"][0]["thread_id"]]["values"]["messages"]
original = messages[0]
# Reproduce the real Gateway's admission metadata + dynamic reminder split.
messages[:] = [{"type": "system", "id": original["id"], "content": "Current date"}, {**original, "id": original["id"] + "__user", "additional_kwargs": {"run_id": job["id"]}}]
runs.finish(job["id"], "Correctly attributed")
await service.tick()
view = await actions["get"]({"team_id": team["id"]}, context(runs))
assert view["jobs"][0]["status"] == "completed"
assert view["messages"][-1]["text"] == "Correctly attributed"
@pytest.mark.asyncio
async def test_shared_thread_access_does_not_grant_foreign_team_access(plugin):
from deerflow_extension_api.agent_runs import AGENT_RUNS_CONTEXT_KEY
from langchain_core.messages import HumanMessage
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
await runs.create_thread(assistant_id="lead_agent", thread_id="source", metadata={})
# This host double permits thread reads to both callers, like a shared
# conversation. Plugin team data must still use the authenticated owner.
runtime = SimpleNamespace(context={"thread_id": "source", "user_id": "bob", AGENT_RUNS_CONTEXT_KEY: runs})
message = HumanMessage(id="foreign", content="Send", additional_kwargs={"extension_mentions": [{"namespace": "community.agent-teams", "provider": "members", "id": f"{team['id']}/{member['id']}", "label": "Spoof"}]})
with pytest.raises(ValueError):
await service.mentions([message], runtime)
assert not runs.starts
@pytest.mark.asyncio
async def test_result_context_is_bounded_and_old_pending_jobs_are_paged(plugin):
import json
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
for i in range(25):
await actions["send"]({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "资料" * 1000, "request_id": str(i)}, context(runs))
view = await actions["get"]({"team_id": team["id"]}, context(runs))
assert view["total_jobs"] == 25
assert len(view["jobs"]) == 20
assert len(json.dumps(view, ensure_ascii=False).encode()) < 64 * 1024
second = await actions["get"]({"team_id": team["id"], "offset": 20}, context(runs))
assert len(second["jobs"]) == 5
assert {j["id"] for j in view["jobs"]}.isdisjoint(j["id"] for j in second["jobs"])
@pytest.mark.asyncio
async def test_handoff_chain_cannot_grow_without_bound(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
first, second = team["members"]
root = await actions["send"]({"team_id": team["id"], "member_id": first["id"], "text": "Begin", "request_id": "root"}, context(runs))
await service.tick()
for i in range(11):
await actions["send"]({"team_id": team["id"], "member_id": second["id"], "text": "Review", "request_id": str(i)}, context(runs, thread=first["thread_id"]))
with pytest.raises(ValueError, match="limit"):
await actions["send"]({"team_id": team["id"], "member_id": second["id"], "text": "Review", "request_id": "overflow"}, context(runs, thread=first["thread_id"]))
stored = await service.db("get", "alice", team["id"])
assert {j["root"] for j in stored["jobs"]} == {root["id"]}
@pytest.mark.asyncio
async def test_activity_keeps_task_result_and_handoff_provenance_after_context_rotation(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
sender, recipient = team["members"]
first = await actions["send"]({"team_id": team["id"], "member_id": sender["id"], "text": "Research the release", "request_id": "initial"}, context(runs))
await service.tick()
peer = await actions["send"]({"team_id": team["id"], "member_id": recipient["id"], "text": "Review the evidence", "request_id": "peer"}, context(runs, thread=sender["thread_id"]))
await service.tick()
runs.finish(peer["id"], "Review passed")
await service.tick()
await service.db("change", "alice", team["id"], lambda t: t.update(messages=[]))
view = await actions["get"]({"team_id": team["id"], "limit": 200, "details": True}, context(runs))
job = next(j for j in view["jobs"] if j["id"] == peer["id"])
assert job["text"] == "Review the evidence"
assert job["result"] == "Review passed"
assert job["source_member_id"] == sender["id"]
assert job["parent_id"] == first["id"]
assert job["root"] == first["id"]
unchanged = await actions["get"]({"team_id": team["id"], "limit": 200, "details": True, "revision": view["revision"]}, context(runs))
assert unchanged == {"unchanged": True, "revision": view["revision"]}
assert job["created_at"]
assert job["completed_at"]
assert job["delivery"]["thread_id"] == sender["thread_id"]
assert job["delivery"]["status"] == "queued"
assert not {"input", "resume", "initial_run_id"} & job.keys()
with pytest.raises(ValueError):
await actions["get"]({"team_id": team["id"], "limit": 200, "details": True}, context(runs, "bob"))
@pytest.mark.asyncio
async def test_activity_reads_legacy_jobs_and_bounds_projection(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
sent = await actions["send"]({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "Legacy request", "request_id": "legacy"}, context(runs))
await service.tick()
runs.finish(sent["id"], "Legacy result")
await service.tick()
def old_version(t):
for job in t["jobs"]:
for key in ("result", "parent_id", "created_at", "completed_at"):
job.pop(key, None)
await service.db("change", "alice", team["id"], old_version)
view = await actions["get"]({"team_id": team["id"], "limit": 1, "details": True}, context(runs))
assert view["jobs"][0]["result"] == "Legacy result"
assert view["jobs"][0]["created_at"] is None
for limit in (0, 201, True, "20"):
with pytest.raises(ValueError):
await actions["get"]({"team_id": team["id"], "limit": limit}, context(runs))
@pytest.mark.asyncio
@pytest.mark.parametrize("created_threads", [1, 2])
async def test_reconnect_recovers_incomplete_creation_only_after_all_threads_exist(plugin, monkeypatch, created_threads):
from deerflow_extension_agent_teams.service import Teams
_, actions, service = plugin
runs = Runs()
ensure = runs.create_thread
calls = []
async def interrupted(**kwargs):
result = await ensure(**kwargs)
calls.append(result)
if len(calls) == created_threads:
raise TimeoutError("creation interrupted")
return result
monkeypatch.setattr(runs, "create_thread", interrupted)
with pytest.raises(TimeoutError):
await create(actions, runs)
team = (await service.db("list", "alice"))[0]
assert not team["ready"]
original_ids = [m["thread_id"] for m in team["members"]]
replacement = Teams(service.store.path)
replacement.available = True
calls.clear()
with pytest.raises(TimeoutError):
await replacement.connect({"team_id": team["id"]}, context(runs))
assert not (await replacement.db("get", "alice", team["id"]))["ready"]
assert not replacement.handles
monkeypatch.setattr(runs, "create_thread", ensure)
await replacement.connect({"team_id": team["id"]}, context(runs))
await replacement.connect({"team_id": team["id"]}, context(runs))
recovered = await replacement.get({"team_id": team["id"]}, context(runs))
assert recovered["ready"] and recovered["connected"]
assert [m["thread_id"] for m in recovered["members"]] == original_ids
assert set(runs.threads) == set(original_ids)
sent = await replacement.send({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "Recovered task", "request_id": "recovered"}, context(runs))
await replacement.tick()
runs.finish(sent["id"])
await replacement.tick()
assert (await replacement.get({"team_id": team["id"]}, context(runs)))["jobs"][0]["status"] == "completed"
@pytest.mark.asyncio
async def test_reconnect_and_delete_leave_no_orphaned_handle(plugin, monkeypatch):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
service.handles.clear()
ensure = runs.create_thread
deletion = None
attempted = asyncio.Event()
db = service.db
async def delete():
attempted.set()
return await actions["delete"]({"team_id": team["id"]}, context(runs))
async def immediate_delete_db(method, *args):
# Once admitted, finish the competing deletion in one event-loop turn;
# executor timing must not let an unlocked reconnect escape this test.
if asyncio.current_task() is deletion:
return getattr(service.store, method)(*args)
return await db(method, *args)
async def delete_during_reconnect(**kwargs):
nonlocal deletion
if deletion is None:
deletion = asyncio.create_task(delete())
await attempted.wait()
return await ensure(**kwargs)
monkeypatch.setattr(service, "db", immediate_delete_db)
monkeypatch.setattr(runs, "create_thread", delete_during_reconnect)
await actions["connect"]({"team_id": team["id"]}, context(runs))
await deletion
assert not service.handles
assert (await actions["list"]({}, context(runs)))["teams"] == []
await service.tick()
@pytest.mark.asyncio
@pytest.mark.parametrize("finish_as_admitted", [True, False])
async def test_request_capacity_reserves_all_receipts_and_keeps_retries_idempotent(plugin, finish_as_admitted):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
sender, recipient = team["members"]
caller = context(runs, thread=sender["thread_id"])
payload = {"team_id": team["id"], "member_id": recipient["id"], "text": "Check", "request_id": "0"}
async def complete(job_id):
stored = await service.db("get", "alice", team["id"])
job = next(j for j in stored["jobs"] if j["id"] == job_id)
await service.finish("alice", stored, job, "completed", "Result")
# Duplicate terminal observations cannot allocate a second receipt.
await service.finish("alice", stored, job, "completed", "Result")
stored = await service.db("get", "alice", team["id"])
receipt = next(j for j in stored["jobs"] if j.get("parent_id") == job_id)
await service.finish("alice", stored, receipt, "completed", "Delivered")
ids = []
for index in range(100):
sent = await actions["send"]({**payload, "request_id": str(index)}, caller)
ids.append(sent["id"])
if finish_as_admitted:
await complete(sent["id"])
if not finish_as_admitted:
for job_id in ids:
await complete(job_id)
view = await actions["get"]({"team_id": team["id"], "limit": 200}, context(runs))
assert view["total_jobs"] == len(view["jobs"]) == 200
assert sum(j["kind"] == "request" for j in view["jobs"]) == 100
assert sum(j["kind"] == "receipt" for j in view["jobs"]) == 100
assert (await actions["send"](payload, caller))["id"] == ids[0]
with pytest.raises(ValueError, match="limit"):
await actions["send"]({**payload, "request_id": "101"}, caller)
assert (await actions["get"]({"team_id": team["id"]}, context(runs)))["total_jobs"] == 200
@pytest.mark.asyncio
@pytest.mark.parametrize("source_kind", ["peer", "mention", "receipt"])
@pytest.mark.parametrize("rounds", [1, 2])
async def test_real_clarification_waits_and_continues_with_human_messages(plugin, monkeypatch, source_kind, rounds):
from deerflow_extension_api.agent_runs import AGENT_RUNS_CONTEXT_KEY
from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.tools.builtins.clarification_tool import ask_clarification_tool
class Model(FakeMessagesListChatModel):
def bind_tools(self, tools, **kwargs):
return self
question = AIMessage(content="", tool_calls=[{"name": "ask_clarification", "id": "clarify-environment", "args": {"question": "Which environment?", "clarification_type": "missing_info"}, "type": "tool_call"}])
questions = [question.model_copy(update={"tool_calls": [{**question.tool_calls[0], "id": f"clarify-{index}"}]}) for index in range(rounds)]
graph = create_agent(Model(responses=[*questions, AIMessage(content="Confirmed staging.")]), tools=[ask_clarification_tool], middleware=[ClarificationMiddleware()], checkpointer=InMemorySaver())
class ClarifyingRuns(Runs):
async def start(self, *, thread_id, input, idempotency_key):
if idempotency_key in self.runs:
return self.runs[idempotency_key]
run = await super().start(thread_id=thread_id, input=input, idempotency_key=idempotency_key)
await graph.ainvoke(input, config={"configurable": {"thread_id": thread_id}})
state = await graph.aget_state({"configurable": {"thread_id": thread_id}})
self.threads[thread_id].update(values={"messages": [m.model_dump() for m in state.values["messages"]]}, next=list(state.next), interrupts=list(state.interrupts))
self.runs[run.run_id] = AgentRun(thread_id, run.run_id, "success")
return self.runs[run.run_id]
async def resume(self, **kwargs):
pytest.fail("Ordinary clarification must not use LangGraph resume")
_, actions, service = plugin
runs = ClarifyingRuns()
team = await create(actions, runs)
sender, recipient = team["members"]
if source_kind == "mention":
await runs.create_thread(assistant_id="lead_agent", thread_id="external", metadata={})
message = HumanMessage(id="mention", content="Review", additional_kwargs={"extension_mentions": [{"namespace": "community.agent-teams", "provider": "members", "id": f"{team['id']}/{recipient['id']}"}]})
await service.mentions([message], SimpleNamespace(context={"thread_id": "external", "user_id": "alice", AGENT_RUNS_CONTEXT_KEY: runs}))
else:
await actions["send"]({"team_id": team["id"], "member_id": recipient["id"], "text": "Review", "request_id": "peer"}, context(runs, thread=sender["thread_id"]))
if source_kind == "receipt":
stored = await service.db("get", "alice", team["id"])
await service.finish("alice", stored, stored["jobs"][0], "completed", "Peer result")
await service.tick()
view = await actions["get"]({"team_id": team["id"], "details": True}, context(runs))
waiting = view["jobs"][-1]
assert waiting["status"] == "waiting_input"
assert waiting["clarification"]["question"] == "Which environment?"
assert len(view["jobs"]) == (2 if source_kind == "receipt" else 1)
state = runs.threads[waiting["thread_id"]]
assert not state["next"] and not state["interrupts"]
request = state["values"]["messages"][-1]["artifact"]["human_input"]
assert request["kind"] == "human_input_request"
assert runs.runs[waiting["run_id"]].status == "success"
await service.tick()
assert len(runs.starts) == 1
for index in range(rounds):
await actions["resume"]({"team_id": team["id"], "job_id": waiting["id"], "response": "staging", "request_id": "answer"}, context(runs))
if source_kind == "mention" and index == 0:
from deerflow_extension_agent_teams.service import Teams
start = runs.start
attempts = []
async def lost_response(**kwargs):
attempts.append(kwargs)
result = await start(**kwargs)
if len(attempts) == 1:
raise TimeoutError("response admitted but acknowledgement lost")
return result
monkeypatch.setattr(runs, "start", lost_response)
await service.tick()
# Admission metadata survives the host's dynamic-context ID rewrite.
messages = runs.threads[waiting["thread_id"]]["values"]["messages"]
response_id = attempts[0]["input"]["messages"][0]["id"]
position = next(i for i, message in enumerate(messages) if message.get("id") == response_id)
original = messages[position]
messages[position : position + 1] = [
{"type": "system", "id": response_id, "content": "Current date"},
{**original, "id": response_id + "__user", "additional_kwargs": {**original["additional_kwargs"], "run_id": attempts[0]["idempotency_key"]}},
]
service = Teams(service.store.path)
service.available = True
await service.connect({"team_id": team["id"]}, context(runs))
actions = {name: getattr(service, name) for name in actions}
await service.tick()
assert attempts[0] == attempts[1]
monkeypatch.setattr(runs, "start", start)
else:
await service.tick()
view = await actions["get"]({"team_id": team["id"], "details": True}, context(runs))
current = next(j for j in view["jobs"] if j["id"] == waiting["id"])
assert current["status"] == ("waiting_input" if index + 1 < rounds else "completed")
assert len(runs.starts) == index + 2
view = await actions["get"]({"team_id": team["id"], "details": True}, context(runs))
completed = next(j for j in view["jobs"] if j["id"] == waiting["id"])
assert completed["status"] == "completed"
assert completed["result"] == "Confirmed staging."
response = runs.starts[1][1]["messages"][0]
assert response["role"] == "user" and response["content"] == "staging"
assert response["additional_kwargs"]["human_input_response"]["request_id"] == request["request_id"]
assert sum(j["kind"] == "receipt" for j in view["jobs"]) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("ambiguous_admission", [False, True])
async def test_peer_handoff_keeps_running_parent_and_budget_with_later_queued_requests(plugin, monkeypatch, ambiguous_admission):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
first, second = team["members"]
payload = {"team_id": team["id"], "member_id": first["id"], "text": "Begin", "request_id": "A1"}
running = await actions["send"](payload, context(runs))
if ambiguous_admission:
start = runs.start
async def lost_admission(**kwargs):
await start(**kwargs)
raise TimeoutError("admission accepted")
monkeypatch.setattr(runs, "start", lost_admission)
await service.tick()
await actions["send"]({**payload, "request_id": "A2"}, context(runs))
peer_payload = {**payload, "member_id": second["id"], "text": "Review"}
for index in range(11):
sent = await actions["send"]({**peer_payload, "request_id": f"peer-{index}"}, context(runs, thread=first["thread_id"]))
view = await actions["get"]({"team_id": team["id"]}, context(runs))
peer = next(j for j in view["jobs"] if j["id"] == sent["id"])
assert peer["parent_id"] == peer["root"] == running["id"]
await actions["send"]({**payload, "request_id": "A3"}, context(runs))
with pytest.raises(ValueError, match="limit"):
await actions["send"]({**peer_payload, "request_id": "overflow"}, context(runs, thread=first["thread_id"]))
assert len(runs.starts) == 1
@pytest.mark.asyncio
async def test_external_member_tool_call_does_not_inherit_an_unstarted_team_request(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
first, second = team["members"]
await actions["send"]({"team_id": team["id"], "member_id": first["id"], "text": "Queued", "request_id": "queued"}, context(runs))
sent = await actions["send"]({"team_id": team["id"], "member_id": second["id"], "text": "From ordinary member chat", "request_id": "peer"}, context(runs, thread=first["thread_id"]))
view = await actions["get"]({"team_id": team["id"]}, context(runs))
peer = next(j for j in view["jobs"] if j["id"] == sent["id"])
assert peer["parent_id"] is None and peer["root"] == peer["id"]
@pytest.mark.asyncio
@pytest.mark.parametrize("stage", ["queued", "waiting", "resuming"])
async def test_clarification_never_overwrites_an_external_conversation_turn(plugin, stage):
from langchain_core.messages import AIMessage
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
request = SimpleNamespace(tool_call={"name": "ask_clarification", "id": "outside", "args": {"question": "Which environment?", "clarification_type": "missing_info"}}, runtime=None)
command = ClarificationMiddleware().wrap_tool_call(request, lambda _: pytest.fail("tool handler should be intercepted"))
sent = await actions["send"]({"team_id": team["id"], "member_id": member["id"], "text": "Work", "request_id": "job"}, context(runs))
if stage != "queued":
await service.tick()
runs.runs[sent["id"]] = AgentRun(member["thread_id"], sent["id"], "success")
messages = runs.threads[member["thread_id"]]["values"]["messages"]
messages.extend([AIMessage(content="", tool_calls=[{**request.tool_call, "type": "tool_call"}]).model_dump(), *[m.model_dump() for m in command.update["messages"]]])
await service.tick()
response = {"team_id": team["id"], "job_id": sent["id"], "response": "staging", "request_id": "response"}
if stage == "queued":
assert not runs.starts
else:
with pytest.raises(ValueError, match="Invalid response"):
await actions["resume"]({**response, "response": {"approved": True}}, context(runs))
if stage == "resuming":
await actions["resume"](response, context(runs))
messages.append({"type": "human", "content": "I answered directly in the member chat"})
if stage == "waiting":
with pytest.raises(ValueError, match="Clarification changed"):
await actions["resume"](response, context(runs))
await service.tick()
assert len(runs.starts) == 1
job = (await actions["get"]({"team_id": team["id"]}, context(runs)))["jobs"][0]
assert job["status"] == {"queued": "running", "waiting": "waiting_input", "resuming": "failed"}[stage]
@pytest.mark.asyncio
async def test_cancel_reconciles_a_clarification_response_with_lost_admission_ack(plugin, monkeypatch):
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
sender, member = team["members"]
sent = await actions["send"]({"team_id": team["id"], "member_id": member["id"], "text": "Work", "request_id": "job"}, context(runs, thread=sender["thread_id"]))
await service.tick()
runs.runs[sent["id"]] = AgentRun(member["thread_id"], sent["id"], "success")
request = SimpleNamespace(tool_call={"name": "ask_clarification", "id": "question", "args": {"question": "Which environment?", "clarification_type": "missing_info"}}, runtime=None)
command = ClarificationMiddleware().wrap_tool_call(request, lambda _: pytest.fail("intercept clarification"))
runs.threads[member["thread_id"]]["values"]["messages"].extend(m.model_dump() for m in command.update["messages"])
await service.tick()
await actions["resume"]({"team_id": team["id"], "job_id": sent["id"], "response": "staging", "request_id": "answer"}, context(runs))
start = runs.start
attempts = []
async def lost_ack(**kwargs):
attempts.append(kwargs)
result = await start(**kwargs)
if len(attempts) == 1:
raise TimeoutError("ack lost")
return result
monkeypatch.setattr(runs, "start", lost_ack)
await service.tick()
response_run = next(r for r in runs.runs.values() if r.run_id != sent["id"])
assert response_run.status == "running"
await actions["cancel"]({"team_id": team["id"], "job_id": sent["id"]}, context(runs))
await service.tick()
assert runs.runs[response_run.run_id].status == "interrupted"
assert attempts[0] == attempts[1]
await service.tick()
view = await actions["get"]({"team_id": team["id"]}, context(runs))
assert view["jobs"][0]["status"] == "cancelled"
assert len(view["jobs"]) == 1 and len(runs.starts) == 2
async def waiting_job(actions, service, runs, *, clarification=False):
"""Park a job for either an explicit interrupt or an ordinary clarification."""
team = await create(actions, runs)
sent = await actions["send"]({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "Check release", "request_id": "one"}, context(runs))
await service.tick()
runs.finish(sent["id"], interrupted=not clarification)
if clarification:
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
request = SimpleNamespace(tool_call={"name": "ask_clarification", "id": "question", "args": {"question": "Which environment?", "clarification_type": "missing_info"}}, runtime=None)
command = ClarificationMiddleware().wrap_tool_call(request, lambda _: pytest.fail("intercept clarification"))
runs.threads[team["members"][0]["thread_id"]]["values"]["messages"].extend(m.model_dump() for m in command.update["messages"])
await service.tick()
assert (await actions["get"]({"team_id": team["id"]}, context(runs)))["jobs"][0]["status"] == "waiting_input"
return team, sent
@pytest.mark.asyncio
@pytest.mark.parametrize(
"answer",
[
pytest.param("漢" * 2000, id="cjk-2000-chars"),
pytest.param("🙂" * 1000, id="emoji-1000-chars"),
pytest.param("a" * 4000, id="ascii-4000-chars"),
pytest.param('"' * 4000, id="json-quotes"),
pytest.param("\n" * 3999 + "a", id="json-newlines"),
],
)
async def test_a_clarification_answer_within_the_documented_bounds_is_accepted(plugin, answer):
# README: tasks and clarification answers accept up to 4,000 characters / 8,000 UTF-8 bytes,
# and the UI validates exactly those two bounds before submitting.
_, actions, service = plugin
runs = Runs()
team, sent = await waiting_job(actions, service, runs)
await actions["resume"]({"team_id": team["id"], "job_id": sent["id"], "response": answer, "request_id": "answer"}, context(runs))
assert (await actions["get"]({"team_id": team["id"]}, context(runs)))["jobs"][0]["status"] == "resuming"
@pytest.mark.asyncio
@pytest.mark.parametrize("answer", ["漢" * 3000, {"text": "x" * 9000}])
async def test_an_oversized_clarification_answer_is_still_refused(plugin, answer):
_, actions, service = plugin
runs = Runs()
team, sent = await waiting_job(actions, service, runs)
with pytest.raises(ValueError, match="8 KiB"):
await actions["resume"]({"team_id": team["id"], "job_id": sent["id"], "response": answer, "request_id": "answer"}, context(runs))
assert (await actions["get"]({"team_id": team["id"]}, context(runs)))["jobs"][0]["status"] == "waiting_input"
@pytest.mark.asyncio
@pytest.mark.parametrize("clarification", [False, True], ids=["interrupt", "clarification"])
@pytest.mark.parametrize("size", [7999, 8000, 8001])
async def test_string_response_uses_the_raw_utf8_byte_boundary(plugin, clarification, size):
_, actions, service = plugin
runs = Runs()
team, sent = await waiting_job(actions, service, runs, clarification=clarification)
answer = "漢" * 2666 + "a" * (size - 7998)
payload = {"team_id": team["id"], "job_id": sent["id"], "response": answer, "request_id": "answer"}
if size > 8000:
with pytest.raises(ValueError, match="8 KiB"):
await actions["resume"](payload, context(runs))
else:
await actions["resume"](payload, context(runs))
job = (await service.db("get", "alice", team["id"]))["jobs"][0]
assert job["status"] == ("waiting_input" if size > 8000 else "resuming")
if size <= 8000:
assert job["resume"]["response"] == answer
if clarification:
assert job["resume"]["input"]["messages"][0]["content"] == answer
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [7999, 8000, 8001])
async def test_structured_response_keeps_the_utf8_json_byte_boundary(plugin, size):
_, actions, service = plugin
runs = Runs()
team, sent = await waiting_job(actions, service, runs)
# The JSON object adds 12 bytes around its string value.
answer = {"text": "漢" * 2660 + "a" * (size - 7980 - 12)}
payload = {"team_id": team["id"], "job_id": sent["id"], "response": answer, "request_id": "answer"}
if size > 8000:
with pytest.raises(ValueError, match="8 KiB"):
await actions["resume"](payload, context(runs))
else:
await actions["resume"](payload, context(runs))
job = (await service.db("get", "alice", team["id"]))["jobs"][0]
assert job["status"] == ("waiting_input" if size > 8000 else "resuming")
if size <= 8000:
assert job["resume"]["response"] == answer
@pytest.mark.asyncio
@pytest.mark.parametrize("response_json", ['"\\ud800"', '"\\udc00"', '{"text": "\\ud800"}', '{"\\udc00": true}'])
async def test_malformed_unicode_response_uses_the_friendly_validation_error(plugin, response_json):
_, actions, service = plugin
runs = Runs()
team, sent = await waiting_job(actions, service, runs)
with pytest.raises(ValueError, match="^An explicit response of at most 8 KiB is required$"):
await actions["resume"]({"team_id": team["id"], "job_id": sent["id"], "response": json.loads(response_json), "request_id": "answer"}, context(runs))
job = (await service.db("get", "alice", team["id"]))["jobs"][0]
assert job["status"] == "waiting_input"
assert job["resume"] is None
async def scheduling_team(actions, runs, owner, key, members=2):
return await actions["create"]({"request_id": key, "name": key, "goal": "Check scheduling", "members": [{"name": f"Member {i}", "agent": "researcher"} for i in range(members)]}, context(runs, owner))
async def scheduling_send(actions, runs, owner, team, member, key):
return await actions["send"]({"team_id": team["id"], "member_id": member["id"], "text": "Check", "request_id": key + member["id"]}, context(runs, owner))
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ["create", "send", "connect", "cancel", "resume", "delete"])
async def test_slow_team_does_not_block_another_teams_control(plugin, monkeypatch, operation):
_, actions, service = plugin
runs = Runs()
slow = await scheduling_team(actions, runs, "alice", "Slow")
healthy = await scheduling_team(actions, runs, "bob", "Healthy")
slow_job = await scheduling_send(actions, runs, "alice", slow, slow["members"][0], "slow")
healthy_job = None
if operation in ("cancel", "resume"):
healthy_job = await scheduling_send(actions, runs, "bob", healthy, healthy["members"][0], "healthy")
await service.tick()
if operation == "resume":
runs.finish(healthy_job["id"], interrupted=True)
await service.tick()
entered, release = asyncio.Event(), asyncio.Event()
get = runs.get
async def stalled(**kwargs):
if kwargs["thread_id"] == slow["members"][0]["thread_id"]:
entered.set()
await release.wait()
return await get(**kwargs)
monkeypatch.setattr(runs, "get", stalled)
tick = asyncio.create_task(service.tick())
control = None
try:
await asyncio.wait_for(entered.wait(), 2)
if operation == "create":
control = asyncio.create_task(scheduling_team(actions, runs, "bob", "New"))
elif operation == "send":
control = asyncio.create_task(scheduling_send(actions, runs, "bob", healthy, healthy["members"][1], "second"))
else:
payload = {"team_id": healthy["id"]}
if healthy_job:
payload["job_id"] = healthy_job["id"]
if operation == "resume":
payload.update(response={"approved": True}, request_id="answer")
control = asyncio.create_task(actions[operation](payload, context(runs, "bob")))
await asyncio.wait_for(asyncio.shield(control), 2)
assert not release.is_set()
assert runs.runs[slow_job["id"]].status == "running"
finally:
release.set()
await tick
if control:
await control
@pytest.mark.asyncio
async def test_new_admissions_are_round_robin_by_owner_not_team_count(plugin):
_, actions, service = plugin
runs = Runs()
owners = {}
for owner, count, members in (("alice", 4, 2), ("bob", 1, 8)):
for index in range(count):
team = await scheduling_team(actions, runs, owner, f"{owner}-{index}", members)
for member in team["members"]:
owners[member["thread_id"]] = owner
await scheduling_send(actions, runs, owner, team, member, "first")
await service.tick()
started = [owners[thread] for thread, _ in runs.starts]
assert len(started) == 8
assert started.count("alice") == started.count("bob") == 4
@pytest.mark.asyncio
async def test_owner_rotation_survives_one_slot_at_a_time(plugin):
_, actions, service = plugin
runs = Runs()
owners = {}
for owner in ("alice", "bob"):
team = await scheduling_team(actions, runs, owner, owner, 8)
for member in team["members"]:
owners[member["thread_id"]] = owner
await scheduling_send(actions, runs, owner, team, member, "first")
await service.tick()
admitted = []
for _ in range(4):
first = next(run for run in runs.runs.values() if run.status == "running")
runs.finish(first.run_id)
before = len(runs.starts)
await service.tick()
assert len(runs.starts) == before + 1
admitted.append(owners[runs.starts[-1][0]])
assert sum(run.status == "running" for run in runs.runs.values()) == 8
assert admitted == ["alice", "bob", "alice", "bob"]
@pytest.mark.asyncio
async def test_owner_rotation_finishes_the_round_after_single_job_owners_leave_pending(plugin):
_, actions, service = plugin
runs = Runs()
owners = {}
for owner, members in [("alice", 8), *((f"owner-{i}", 2) for i in range(1, 17))]:
team = await scheduling_team(actions, runs, owner, owner, members)
for member in team["members"][: 8 if owner == "alice" else 1]:
owners[member["thread_id"]] = owner
await scheduling_send(actions, runs, owner, team, member, "first")
await service.tick()
assert {owners[thread] for thread, _ in runs.starts} == {"alice", *(f"owner-{i}" for i in range(1, 8))}
admitted = []
for _ in range(10):
first = next(run for run in runs.runs.values() if run.status == "running")
runs.finish(first.run_id)
before = len(runs.starts)
await service.tick()
assert len(runs.starts) == before + 1
admitted.append(owners[runs.starts[-1][0]])
assert sum(run.status == "running" for run in runs.runs.values()) == 8
assert admitted == [*(f"owner-{i}" for i in range(8, 17)), "alice"]
@pytest.mark.asyncio
@pytest.mark.parametrize("cancel_waiting_owner", [False, True])
async def test_owner_rotation_appends_new_owners_and_drops_cancelled_queues(plugin, cancel_waiting_owner):
_, actions, service = plugin
runs = Runs()
owners, waiting = {}, None
for owner, members in [("alice", 8), *((f"owner-{i}", 2) for i in range(1, 9))]:
team = await scheduling_team(actions, runs, owner, owner, members)
for member in team["members"][: 8 if owner == "alice" else 1]:
owners[member["thread_id"]] = owner
sent = await scheduling_send(actions, runs, owner, team, member, "first")
if owner == "owner-8":
waiting = (team, sent)
await service.tick()
late = await scheduling_team(actions, runs, "late", "late")
owners[late["members"][0]["thread_id"]] = "late"
await scheduling_send(actions, runs, "late", late, late["members"][0], "first")
if cancel_waiting_owner:
team, sent = waiting
await actions["cancel"]({"team_id": team["id"], "job_id": sent["id"]}, context(runs, "owner-8"))
await actions["delete"]({"team_id": team["id"]}, context(runs, "owner-8"))
admitted = []
for _ in range(4):
first = next(run for run in runs.runs.values() if run.status == "running")
runs.finish(first.run_id)
before = len(runs.starts)
await service.tick()
assert len(runs.starts) == before + 1
admitted.append(owners[runs.starts[-1][0]])
assert sum(run.status == "running" for run in runs.runs.values()) == 8
assert admitted == (["alice", "late", "alice", "alice"] if cancel_waiting_owner else ["owner-8", "alice", "late", "alice"])
# With only Alice's backlog left, spare slots must remain usable.
for run in list(runs.runs.values()):
if run.status == "running":
runs.finish(run.run_id)
await service.tick()
assert len(runs.starts) == (16 if cancel_waiting_owner else 17)
for run in list(runs.runs.values()):
if run.status == "running":
runs.finish(run.run_id)
await service.tick()
assert not any(run.status == "running" for run in runs.runs.values())
@pytest.mark.asyncio
async def test_background_loop_keeps_cancelling_other_teams_while_one_call_stalls(plugin, monkeypatch):
_, actions, service = plugin
runs = Runs()
slow = await scheduling_team(actions, runs, "alice", "Slow")
healthy = await scheduling_team(actions, runs, "bob", "Healthy")
await scheduling_send(actions, runs, "alice", slow, slow["members"][0], "slow")
sent = await scheduling_send(actions, runs, "bob", healthy, healthy["members"][0], "healthy")
entered, healthy_running, cancelled, release = (asyncio.Event() for _ in range(4))
get, cancel = runs.get, runs.cancel
async def stalled(**kwargs):
if kwargs["thread_id"] == slow["members"][0]["thread_id"]:
entered.set()
await release.wait()
else:
healthy_running.set()
return await get(**kwargs)
async def record_cancel(**kwargs):
await cancel(**kwargs)
cancelled.set()
monkeypatch.setattr(runs, "get", stalled)
monkeypatch.setattr(runs, "cancel", record_cancel)
await service.start(None)
try:
await asyncio.wait_for(entered.wait(), 3)
await asyncio.wait_for(healthy_running.wait(), 3)
await asyncio.wait_for(actions["cancel"]({"team_id": healthy["id"], "job_id": sent["id"]}, context(runs, "bob")), 2)
await asyncio.wait_for(cancelled.wait(), 3)
assert not release.is_set()
# Shutdown cancels and drains gated workers; it never needs the host gate.
await asyncio.wait_for(service.stop(), 3)
assert not service.workers and not service.in_flight
assert not service.available and service.process_lock is None
finally:
release.set()
await service.stop()
@pytest.mark.asyncio
async def test_overlapping_ticks_keep_pre_freeze_reservations_within_eight(plugin, monkeypatch):
_, actions, service = plugin
runs = Runs()
for index in range(9):
owner = f"owner-{index}"
team = await scheduling_team(actions, runs, owner, owner)
await scheduling_send(actions, runs, owner, team, team["members"][0], "first")
entered, release = asyncio.Event(), asyncio.Event()
get_state = runs.get_state
async def gated(**kwargs):
entered.set()
await release.wait()
return await get_state(**kwargs)
monkeypatch.setattr(runs, "get_state", gated)
first = asyncio.create_task(service.tick())
try:
await asyncio.wait_for(entered.wait(), 3)
await asyncio.wait_for(asyncio.gather(*(service.tick() for _ in range(3))), 2)
assert not runs.starts # No input has frozen; reservations still own slots.
finally:
release.set()
await first
assert len(runs.starts) == 8
assert not service.in_flight and not service.workers
@pytest.mark.asyncio
async def test_more_than_eight_owners_rotate_into_the_next_free_slot(plugin):
_, actions, service = plugin
runs = Runs()
jobs = []
for index in range(9):
owner = f"owner-{index}"
team = await scheduling_team(actions, runs, owner, owner)
jobs.append(await scheduling_send(actions, runs, owner, team, team["members"][0], "first"))
await service.tick()
assert set(runs.runs) == {job["id"] for job in jobs[:8]}
runs.finish(jobs[0]["id"])
await service.tick()
assert jobs[-1]["id"] in runs.runs
assert sum(run.status == "running" for run in runs.runs.values()) == 8
@pytest.mark.asyncio
async def test_pending_resumes_wait_for_slots_and_cancel_without_admitting_an_answer(plugin):
_, actions, service = plugin
runs = Runs()
parked = await scheduling_team(actions, runs, "alice", "Parked")
sent = await scheduling_send(actions, runs, "alice", parked, parked["members"][0], "park")
await service.tick()
runs.finish(sent["id"], interrupted=True)
await service.tick()
busy = await scheduling_team(actions, runs, "bob", "Busy", 8)
for member in busy["members"]:
await scheduling_send(actions, runs, "bob", busy, member, "busy")
await service.tick()
assert sum(run.status == "running" for run in runs.runs.values()) == 8
payload = {"team_id": parked["id"], "job_id": sent["id"], "response": {"approved": True}, "request_id": "answer"}
await actions["resume"](payload, context(runs, "alice"))
await service.tick()
assert len(runs.runs) == 9 # The finished interrupted run plus eight busy runs.
assert (await service.db("get", "alice", parked["id"]))["jobs"][0]["status"] == "resuming"
await actions["cancel"]({"team_id": parked["id"], "job_id": sent["id"]}, context(runs, "alice"))
await service.tick()
assert len(runs.runs) == 9
assert (await service.db("get", "alice", parked["id"]))["jobs"][0]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_frozen_slots_survive_disconnection_and_service_restart(plugin, monkeypatch):
from deerflow_extension_agent_teams.service import Teams
_, actions, service = plugin
runs = Runs()
team = await scheduling_team(actions, runs, "alice", "Busy", 8)
for member in team["members"]:
await scheduling_send(actions, runs, "alice", team, member, "busy")
await service.tick()
runs.denied = True
await service.tick()
assert not service.handles and len(runs.starts) == 8
replacement = Teams(service.store.path)
healthy_runs = Runs()
# The real start loads the durable slot owners even before they reconnect.
await replacement.start(None)
try:
healthy = await scheduling_team({"create": replacement.create}, healthy_runs, "bob", "Healthy")
await scheduling_send({"send": replacement.send}, healthy_runs, "bob", healthy, healthy["members"][0], "new")
await replacement.tick()
assert not healthy_runs.starts
runs.denied = False
await replacement.connect({"team_id": team["id"]}, context(runs))
for run in list(runs.runs.values()):
runs.finish(run.run_id)
await replacement.tick()
await replacement.tick()
assert len(healthy_runs.starts) == 1
finally:
await replacement.stop()
@pytest.mark.asyncio
async def test_receipts_for_a_shared_source_are_serialized_across_teams(plugin):
from deerflow_extension_agent_teams.service import identity
_, actions, service = plugin
runs = Runs()
await runs.create_thread(assistant_id="lead_agent", thread_id="shared", metadata={})
requests = []
for owner in ("alice", "bob"):
team = await scheduling_team(actions, runs, owner, owner)
sent = await service.db("change", owner, team["id"], lambda t: service.enqueue(t, member_id=t["members"][0]["id"], content="Check", request_id="shared-request", source="shared"))
requests.append((owner, team, sent))
await service.tick()
for _, _, job in requests:
runs.finish(job["id"])
await service.tick()
await service.tick()
receipts = []
for owner, team, request in requests:
stored = await service.db("get", owner, team["id"])
receipt = next(j for j in stored["jobs"] if j["id"] == identity(request["id"], "receipt"))
receipts.append((receipt["created_at"], owner, team, receipt))
receipts.sort(key=lambda entry: entry[0])
first, later = receipts
assert first[3]["id"] in runs.runs and later[3]["id"] not in runs.runs
assert later[3]["input"] is None # No competing admission/checkpoint on the source.
runs.finish(first[3]["id"])
await service.tick()
await service.tick()
assert later[3]["id"] in runs.runs
assert sum(thread == "shared" for thread, _ in runs.starts) == 2
@pytest.mark.asyncio
async def test_startup_snapshot_failure_releases_the_process_lock(plugin, monkeypatch):
from deerflow_extension_agent_teams.service import Teams
_, _, service = plugin
db = service.db
async def fail_snapshot(method, *args):
if method == "scheduling_snapshots":
raise OSError("snapshot failed")
return await db(method, *args)
monkeypatch.setattr(service, "db", fail_snapshot)
with pytest.raises(OSError, match="snapshot failed"):
await service.start(None)
assert service.process_lock is None and not service.task
second = Teams(service.store.path)
await second.start(None)
await second.stop()
@pytest.mark.asyncio
async def test_first_unfinished_thread_occupant_does_not_depend_on_timestamps(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
first = await scheduling_send(actions, runs, "alice", team, member, "first")
later = await scheduling_send(actions, runs, "alice", team, member, "later")
await service.db("change", "alice", team["id"], lambda t: t["jobs"][1].pop("created_at"))
await service.tick()
assert first["id"] in runs.runs and later["id"] not in runs.runs
@pytest.mark.asyncio
async def test_cancel_and_delete_before_worker_admission_release_only_that_reservation(plugin, monkeypatch):
_, actions, service = plugin
runs = Runs()
jobs = []
for index in range(9):
owner = f"owner-{index}"
team = await scheduling_team(actions, runs, owner, owner)
sent = await scheduling_send(actions, runs, owner, team, team["members"][0], "first")
jobs.append((owner, team, sent))
entered, release = asyncio.Event(), asyncio.Event()
work_team = service.work_team
ready = 0
async def gated(*args):
nonlocal ready
ready += 1
if ready == 8:
entered.set()
await release.wait()
await work_team(*args)
monkeypatch.setattr(service, "work_team", gated)
tick = asyncio.create_task(service.tick())
try:
await asyncio.wait_for(entered.wait(), 3)
owner, team, sent = jobs[0]
await actions["cancel"]({"team_id": team["id"], "job_id": sent["id"]}, context(runs, owner))
await actions["delete"]({"team_id": team["id"]}, context(runs, owner))
assert not runs.starts
finally:
release.set()
await tick
assert sent["id"] not in runs.runs
assert len(runs.starts) == 8 and jobs[-1][2]["id"] in runs.runs
assert not service.workers and not service.in_flight
@pytest.mark.asyncio
async def test_resume_with_lost_ack_keeps_its_slot_before_any_new_owner_admission(plugin, monkeypatch):
_, actions, service = plugin
runs = Runs()
parked = await scheduling_team(actions, runs, "alice", "Parked")
sent = await scheduling_send(actions, runs, "alice", parked, parked["members"][0], "park")
await service.tick()
runs.finish(sent["id"], interrupted=True)
await service.tick()
busy = await scheduling_team(actions, runs, "bob", "Busy", 8)
for member in busy["members"][:7]:
await scheduling_send(actions, runs, "bob", busy, member, "busy")
await service.tick()
start = runs.resume
async def lost_ack(**kwargs):
await start(**kwargs)
raise TimeoutError("resume admitted; acknowledgement lost")
monkeypatch.setattr(runs, "resume", lost_ack)
await actions["resume"]({"team_id": parked["id"], "job_id": sent["id"], "response": {"approved": True}, "request_id": "answer"}, context(runs, "alice"))
await service.tick()
stored = (await service.db("get", "alice", parked["id"]))["jobs"][0]
assert stored["resume"]["started"]
later = await scheduling_team(actions, runs, "carol", "Later")
queued = await scheduling_send(actions, runs, "carol", later, later["members"][0], "new")
await service.tick()
assert queued["id"] not in runs.runs
assert sum(run.status == "running" for run in runs.runs.values()) == 8
@pytest.mark.asyncio
async def test_scheduler_does_not_read_or_decode_completed_history_per_team(plugin, monkeypatch):
_, actions, service = plugin
runs = Runs()
for index in range(100):
owner = f"archive-{index // 20}"
team = await scheduling_team(actions, runs, owner, f"History {index}")
await scheduling_send(actions, runs, owner, team, team["members"][0], "completed")
await service.db("change", owner, team["id"], lambda t: t["jobs"][0].update(status="completed", input={"messages": []}))
connections, decoded = [], []
connect, decode = service.store.connection, json.loads
def record_connection():
db = connect()
connections.append(db)
return db
def record_decode(*args, **kwargs):
decoded.append(1)
return decode(*args, **kwargs)
monkeypatch.setattr(service.store, "connection", record_connection)
monkeypatch.setattr(json, "loads", record_decode)
await service.tick(wait=False)
assert len(connections) == 1
assert not decoded and not runs.starts
@pytest.mark.asyncio
async def test_deleted_team_does_not_leave_a_repeated_missing_key_lookup(plugin, monkeypatch):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
await service.db("delete", "alice", team["id"])
methods = []
db = service.db
async def record(method, *args):
methods.append(method)
return await db(method, *args)
monkeypatch.setattr(service, "db", record)
await service.tick(wait=False)
await service.tick(wait=False)
assert "get" not in methods
assert not service.workers
@pytest.mark.asyncio
async def test_pending_projection_tracks_queue_wait_finish_and_requeue(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
key = ("alice", team["id"])
assert key not in await service.db("scheduling_snapshots")
sent = await scheduling_send(actions, runs, "alice", team, team["members"][0], "first")
assert key in await service.db("scheduling_snapshots")
await service.tick()
runs.finish(sent["id"], interrupted=True)
await service.tick()
waiting = (await service.db("scheduling_snapshots"))[key]["jobs"][0]
assert waiting["status"] == "waiting_input" and waiting["input"] is not None
assert not service.charged(waiting) # Keep the thread head, release the slot.
await actions["cancel"]({"team_id": team["id"], "job_id": sent["id"]}, context(runs))
await service.tick()
assert key not in await service.db("scheduling_snapshots")
assert (await actions["get"]({"team_id": team["id"]}, context(runs)))["connected"]
runs.threads[team["members"][0]["thread_id"]].update(next=[], interrupts=[])
await scheduling_send(actions, runs, "alice", team, team["members"][0], "second")
assert key in await service.db("scheduling_snapshots")
@pytest.mark.asyncio
async def test_pending_projection_keeps_a_receipt_created_by_terminal_result(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
sender, recipient = team["members"]
sent = await actions["send"]({"team_id": team["id"], "member_id": recipient["id"], "text": "Check", "request_id": "peer"}, context(runs, thread=sender["thread_id"]))
await service.tick()
runs.finish(sent["id"])
await service.tick()
jobs = (await service.db("scheduling_snapshots"))[("alice", team["id"])]["jobs"]
assert jobs[0]["status"] == "completed" and jobs[-1]["kind"] == "receipt"
assert jobs[-1]["status"] == "queued"
def test_pending_projection_backfills_legacy_rows_and_uses_an_index(plugin, tmp_path):
import sqlite3
from contextlib import closing
from deerflow_extension_agent_teams.store import Store
path = tmp_path / "legacy.sqlite"
states = ["completed", "queued", "waiting_input", "resuming", "unknown-legacy-state"]
with closing(sqlite3.connect(path)) as db, db:
db.execute("CREATE TABLE teams (id TEXT PRIMARY KEY, owner TEXT NOT NULL, data TEXT NOT NULL)")
for index, status in enumerate(states):
job = {"status": status, "input": {"messages": []}, "resume": {"started": False}}
db.execute("INSERT INTO teams VALUES (?,?,?)", (str(index), f"owner-{index}", json.dumps({"jobs": [job]})))
store = Store(path)
snapshots = store.scheduling_snapshots()
assert set(snapshots) == {(f"owner-{i}", str(i)) for i in range(1, len(states))}
with closing(store.connection()) as db:
plan = db.execute("EXPLAIN QUERY PLAN SELECT owner, id, data FROM teams WHERE has_pending_jobs=1 ORDER BY rowid").fetchall()
assert any("teams_pending_jobs" in row[3] for row in plan)
# Reopening must use the projection instead of re-decoding legacy history.
again = Store(path)
assert again.scheduling_snapshots() == snapshots
def test_legacy_backfill_finishes_reading_before_updating_the_scanned_table(plugin, tmp_path, monkeypatch):
import sqlite3
from contextlib import closing
from deerflow_extension_agent_teams.store import Store
class BackfillCursor(sqlite3.Cursor):
def fetchall(self):
rows = super().fetchall()
self.connection.scanning = False
return rows
def __next__(self):
try:
return super().__next__()
except StopIteration:
self.connection.scanning = False
raise
class BackfillConnection(sqlite3.Connection):
scanning = False
def execute(self, sql, parameters=()):
if sql.startswith("UPDATE teams"):
assert not self.scanning, "Updating teams before its SELECT has finished"
cursor = self.cursor(BackfillCursor).execute(sql, parameters)
if sql == "SELECT id, data FROM teams":
self.scanning = True
return cursor
path = tmp_path / "legacy-scan.sqlite"
with closing(sqlite3.connect(path)) as db, db:
db.execute("CREATE TABLE teams (id TEXT PRIMARY KEY, owner TEXT NOT NULL, data TEXT NOT NULL)")
for index in range(100):
db.execute("INSERT INTO teams VALUES (?,?,?)", (str(index), "alice", json.dumps({"jobs": [{"status": "completed" if index % 2 else "queued"}], "padding": "x" * 4000})))
monkeypatch.setattr(Store, "connection", lambda store: sqlite3.connect(store.path, factory=BackfillConnection))
assert set(Store(path).scheduling_snapshots()) == {("alice", str(i)) for i in range(0, 100, 2)}
def test_failed_projection_backfill_rolls_back_schema_and_rows(plugin, tmp_path):
import sqlite3
from contextlib import closing
from deerflow_extension_agent_teams.store import Store
path = tmp_path / "invalid-legacy.sqlite"
with closing(sqlite3.connect(path)) as db, db:
db.execute("CREATE TABLE teams (id TEXT PRIMARY KEY, owner TEXT NOT NULL, data TEXT NOT NULL)")
db.execute("INSERT INTO teams VALUES ('first','alice',?)", (json.dumps({"jobs": [{"status": "queued"}]}),))
db.execute("INSERT INTO teams VALUES ('invalid','bob','invalid json')")
with pytest.raises(json.JSONDecodeError):
Store(path)
with closing(sqlite3.connect(path)) as db, db:
assert "has_pending_jobs" not in {row[1] for row in db.execute("PRAGMA table_info(teams)")}
db.execute("UPDATE teams SET data=? WHERE id='invalid'", (json.dumps({"jobs": [{"status": "completed"}]}),))
assert set(Store(path).scheduling_snapshots()) == {("alice", "first")}
@pytest.mark.asyncio
async def test_history_results_remain_retrievable_after_shared_messages_rotate_and_restart(plugin):
from deerflow_extension_agent_teams.service import Teams
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
researcher, reviewer = team["members"]
sent = await scheduling_send(actions, runs, "alice", team, researcher, "research")
await service.tick()
runs.finish(sent["id"], "Original research evidence")
await service.tick()
for index in range(51):
later = await scheduling_send(actions, runs, "alice", team, researcher, f"later-{index}")
stored = await service.db("get", "alice", team["id"])
await service.finish("alice", stored, next(job for job in stored["jobs"] if job["id"] == later["id"]), "completed", "Later evidence")
caller = context(runs, thread=reviewer["thread_id"])
stored = await service.db("get", "alice", team["id"])
assert len(stored["messages"]) == 100
assert not any(message["job_id"] == sent["id"] for message in stored["messages"])
found = await service.search_results({"team_id": team["id"], "member_id": researcher["id"], "query": "ORIGINAL research"}, caller)
assert [item["job_id"] for item in found["items"]] == [sent["id"]]
result = await service.read_result({"team_id": team["id"], "job_id": sent["id"]}, caller)
assert result["result"] == {"available": True, "text": "Original research evidence", "truncated": False, "reason": None}
assert result["member_id"] == researcher["id"] and result["conversation"]["thread_id"] == researcher["thread_id"]
assert result["conversation"]["run_id"] == sent["id"]
replacement = Teams(service.store.path)
assert await replacement.search_results({"team_id": team["id"], "member_id": researcher["id"], "query": "ORIGINAL research"}, caller) == found
assert await replacement.read_result({"team_id": team["id"], "job_id": sent["id"]}, caller) == result
assert not replacement.handles and not replacement.available
assert await service.db("get", "alice", team["id"]) == stored
@pytest.mark.asyncio
async def test_history_pages_keep_their_anchor_when_messages_arrive_and_report_expiry(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
caller = context(runs, thread=member["thread_id"])
jobs = [await scheduling_send(actions, runs, "alice", team, member, str(i)) for i in range(5)]
page = await service.read_history({"team_id": team["id"], "member_id": member["id"], "limit": 2}, caller)
assert [item["job_id"] for item in page["items"]] == [jobs[4]["id"], jobs[3]["id"]]
await scheduling_send(actions, runs, "alice", team, member, "new")
payload = {"team_id": team["id"], "member_id": member["id"], "cursor": page["next_cursor"], "limit": 2}
later = await service.read_history(payload, caller)
assert [item["job_id"] for item in later["items"]] == [jobs[2]["id"], jobs[1]["id"]]
final = await service.read_history({**payload, "cursor": later["next_cursor"]}, caller)
assert [item["job_id"] for item in final["items"]] == [jobs[0]["id"]] and final["next_cursor"] is None
with pytest.raises(ValueError, match="cursor|Cursor"):
await service.read_history({**payload, "member_id": team["members"][1]["id"]}, caller)
await service.db("change", "alice", team["id"], lambda t: t.update(messages=t["messages"][-1:]))
expired = await service.read_history(payload, caller)
assert expired["cursor_expired"] and expired["items"] == [] and expired["next_cursor"] is None
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["read_history", "search_results", "read_result"])
@pytest.mark.parametrize("owner,thread", [("bob", "member"), ("alice", "outside"), ("alice", None)])
async def test_history_tools_require_the_owner_and_a_current_member_thread(plugin, method, owner, thread):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
job = await scheduling_send(actions, runs, "alice", team, team["members"][0], "one")
payload = {"team_id": team["id"], **({"job_id": job["id"]} if method == "read_result" else {})}
actual_thread = team["members"][0]["thread_id"] if thread == "member" else thread
caller = ToolContext(ExtensionPrincipal(owner), {"enabled": True}, actual_thread, agent_runs=runs)
with pytest.raises(ValueError):
await getattr(service, method)(payload, caller)
@pytest.mark.asyncio
@pytest.mark.parametrize("extra", [{"limit": 0}, {"limit": 21}, {"limit": True}, {"cursor": "bad"}, {"query": []}, {"query": " "}, {"member_id": "missing"}, {"owner": "bob"}])
async def test_history_page_options_are_validated(plugin, extra):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
caller = context(runs, thread=team["members"][0]["thread_id"])
with pytest.raises(ValueError):
await service.read_history({"team_id": team["id"], **extra}, caller)
@pytest.mark.asyncio
async def test_history_pages_bound_escaped_unicode_and_keep_forward_progress(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
caller = context(runs, thread=member["thread_id"])
jobs = [await actions["send"]({"team_id": team["id"], "member_id": member["id"], "text": '\\"' * 2000, "request_id": str(i)}, context(runs)) for i in range(20)]
cursor, seen = None, []
while True:
page = await service.read_history({"team_id": team["id"], "limit": 20, **({"cursor": cursor} if cursor else {})}, caller)
assert len(json.dumps(page, ensure_ascii=False).encode()) <= 24000
assert page["items"]
seen.extend(item["job_id"] for item in page["items"])
cursor = page["next_cursor"]
if not cursor:
break
assert seen == [job["id"] for job in reversed(jobs)]
await service.db("change", "alice", team["id"], lambda t: t["messages"][-1].update(text="中😀" * 4000))
page = await service.read_history({"team_id": team["id"], "limit": 1}, caller)
assert page["items"][0]["truncated"] and len(page["items"][0]["text"].encode()) <= 6000
@pytest.mark.asyncio
async def test_history_result_reports_legacy_unavailability_and_never_projects_execution_inputs(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
caller = context(runs, thread=member["thread_id"])
sent = await scheduling_send(actions, runs, "alice", team, member, "old")
stored = await service.db("get", "alice", team["id"])
await service.finish("alice", stored, stored["jobs"][0], "completed", "Legacy evidence")
def legacy(t):
t["jobs"][0].pop("result")
t["jobs"][0].pop("result_truncated", None)
t["jobs"][0].update(input={"private": "frozen-secret"}, resume={"response": "approval-secret"})
await service.db("change", "alice", team["id"], legacy)
result = await service.read_result({"team_id": team["id"], "job_id": sent["id"]}, caller)
assert result["result"]["text"] == "Legacy evidence" and result["result"]["truncated"] is None
page = await service.read_history({"team_id": team["id"], "job_id": sent["id"]}, caller)
assert page["items"][0]["truncated"] is None
assert "frozen-secret" not in json.dumps(result) and "approval-secret" not in json.dumps(result)
await service.db("change", "alice", team["id"], lambda t: t.update(messages=[]))
result = await service.read_result({"team_id": team["id"], "job_id": sent["id"]}, caller)
assert result["result"] == {"available": False, "text": None, "truncated": None, "reason": "not_retained"}
assert result["conversation"]["thread_id"] == member["thread_id"]
@pytest.mark.asyncio
@pytest.mark.parametrize("answer", ["Evidence", "中😀" * 1000], ids=["short", "truncated"])
async def test_history_result_preserves_admission_and_output_truncation_provenance(plugin, answer):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
job = await scheduling_send(actions, runs, "alice", team, member, "one")
caller = context(runs, thread=member["thread_id"])
pending = await service.read_result({"team_id": team["id"], "job_id": job["id"]}, caller)
assert pending["result"]["reason"] == "not_completed"
await service.tick()
runs.finish(job["id"], answer)
await service.tick()
result = await service.read_result({"team_id": team["id"], "job_id": job["id"]}, caller)
assert result["result"]["truncated"] is (len(answer.encode()) > 6000)
assert len(result["result"]["text"].encode()) <= 6000
assert result["status"] == "completed" and result["conversation"]["run_id"] == job["id"]
@pytest.mark.asyncio
async def test_history_result_search_pages_filter_jobs_and_exclude_delivery_receipts(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
sender, recipient = team["members"]
caller = context(runs, thread=sender["thread_id"])
jobs = []
for index in range(4):
sent = await actions["send"]({"team_id": team["id"], "member_id": recipient["id"], "text": "Review original evidence", "request_id": str(index)}, caller)
stored = await service.db("get", "alice", team["id"])
job = next(j for j in stored["jobs"] if j["id"] == sent["id"])
await service.finish("alice", stored, job, "failed" if index == 3 else "completed", "Final evidence")
jobs.append(sent)
cancelled = await scheduling_send(actions, runs, "alice", team, sender, "cancel")
await actions["cancel"]({"team_id": team["id"], "job_id": cancelled["id"]}, context(runs))
cancelled_result = await service.read_result({"team_id": team["id"], "job_id": cancelled["id"]}, caller)
assert cancelled_result["result"]["reason"] == "cancelled"
payload = {"team_id": team["id"], "member_id": recipient["id"], "query": "ORIGINAL", "limit": 2}
first = await service.search_results(payload, caller)
assert [item["job_id"] for item in first["items"]] == [job["id"] for job in reversed(jobs[2:])]
assert first["items"][0]["status"] == "failed"
assert all(item["kind"] == "request" and item["requester_member_id"] == sender["id"] for item in first["items"])
second = await service.search_results({**payload, "cursor": first["next_cursor"]}, caller)
assert [item["job_id"] for item in second["items"]] == [job["id"] for job in reversed(jobs[:2])]
assert second["next_cursor"] is None
one = await service.search_results({"team_id": team["id"], "job_id": jobs[0]["id"]}, caller)
assert one["total_matches"] == 1
assert len(json.dumps(first, ensure_ascii=False).encode()) <= 24000
@pytest.mark.asyncio
async def test_history_tools_dispatch_through_real_tool_node_and_continue_a_peer_handoff(plugin):
from deerflow_extension_api.agent_runs import AGENT_RUNS_CONTEXT_KEY
from langchain_core.messages import AIMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from deerflow.extensions.plugin_tools import build_plugin_tools
loaded, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
researcher, reviewer = team["members"]
job = await scheduling_send(actions, runs, "alice", team, researcher, "research")
await service.tick()
runs.finish(job["id"], "Original evidence for peer review")
await service.tick()
tools = [tool for tool in build_plugin_tools(loaded) if any(name in tool.name for name in ("read_team_history", "search_team_results", "read_team_result"))]
assert len(tools) == 3
graph = StateGraph(MessagesState)
graph.add_node("tools", ToolNode(tools))
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
calls = [{"name": tool.name, "args": {"team_id": team["id"], "job_id": job["id"]}, "id": tool.name} for tool in tools]
compiled = graph.compile()
result = await compiled.ainvoke({"messages": [AIMessage(content="", tool_calls=calls)]}, context={"thread_id": reviewer["thread_id"], "user_id": "alice", AGENT_RUNS_CONTEXT_KEY: runs})
outputs = {message.tool_call_id: json.loads(message.content) for message in result["messages"] if message.type == "tool"}
saved = next(value for name, value in outputs.items() if "read_team_result" in name)
assert saved["result"]["text"] == "Original evidence for peer review"
sent = await actions["send"]({"team_id": team["id"], "member_id": researcher["id"], "text": "Review retrieved evidence: " + saved["result"]["text"], "request_id": "retrieved-handoff"}, context(runs, thread=reviewer["thread_id"]))
await service.tick()
assert sent["id"] in runs.runs
assert "Original evidence for peer review" in runs.starts[-1][1]["messages"][0]["content"]
denied = await compiled.ainvoke({"messages": [AIMessage(content="", tool_calls=calls)]}, context={"thread_id": "outside", "user_id": "alice", AGENT_RUNS_CONTEXT_KEY: runs})
assert all("Original evidence" not in message.content for message in denied["messages"] if message.type == "tool")
@pytest.mark.asyncio
async def test_history_result_pages_and_reads_bound_the_complete_escaped_json(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
caller = context(runs, thread=member["thread_id"])
jobs = []
for index in range(20):
sent = await actions["send"]({"team_id": team["id"], "member_id": member["id"], "text": '\\"' * 2000, "request_id": str(index)}, context(runs))
stored = await service.db("get", "alice", team["id"])
await service.finish("alice", stored, stored["jobs"][-1], "completed", '\\"' * 3000)
jobs.append(sent)
cursor, first_cursor, seen = None, None, []
while True:
payload = {"team_id": team["id"], "limit": 20, **({"cursor": cursor} if cursor else {})}
page = await service.search_results(payload, caller)
assert page["items"] and len(json.dumps(page, ensure_ascii=False).encode()) <= 24000
if not seen:
first_cursor = page["next_cursor"]
seen.extend(item["job_id"] for item in page["items"])
cursor = page["next_cursor"]
if not cursor:
break
assert seen == [job["id"] for job in reversed(jobs)]
result = await service.read_result({"team_id": team["id"], "job_id": jobs[0]["id"]}, caller)
assert len(json.dumps(result, ensure_ascii=False).encode()) <= 24000
assert len(result["result"]["text"].encode()) == 6000
assert "input" not in result and "resume" not in result
assert first_cursor
with pytest.raises(ValueError, match="cursor|Cursor"):
await service.read_history({"team_id": team["id"], "cursor": first_cursor}, caller)
@pytest.mark.asyncio
async def test_history_result_cursors_support_non_hex_job_ids_and_expiry(plugin, monkeypatch):
from deerflow_extension_agent_teams import service as module
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
caller = context(runs, thread=member["thread_id"])
identity = module.identity
monkeypatch.setattr(module, "identity", lambda *parts: "request-" + identity(*parts))
jobs = []
for index in range(2):
sent = await scheduling_send(actions, runs, "alice", team, member, str(index))
stored = await service.db("get", "alice", team["id"])
await service.finish("alice", stored, stored["jobs"][-1], "completed", "Retained evidence")
jobs.append(sent)
payload = {"team_id": team["id"], "limit": 1}
first = await service.search_results(payload, caller)
assert first["items"][0]["job_id"] == jobs[1]["id"]
await scheduling_send(actions, runs, "alice", team, member, "later")
stored = await service.db("get", "alice", team["id"])
await service.finish("alice", stored, stored["jobs"][-1], "completed", "New evidence")
continuation = {**payload, "cursor": first["next_cursor"]}
second = await service.search_results(continuation, caller)
assert [item["job_id"] for item in second["items"]] == [jobs[0]["id"]]
assert second["next_cursor"] is None and not second["cursor_expired"]
await service.db("change", "alice", team["id"], lambda t: t.update(jobs=[job for job in t["jobs"] if job["id"] != jobs[1]["id"]]))
expired = await service.search_results(continuation, caller)
assert expired["cursor_expired"] and expired["items"] == [] and expired["next_cursor"] is None
@pytest.mark.asyncio
async def test_history_result_cursors_issued_before_anchor_hashing_expire(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
caller = context(runs, thread=member["thread_id"])
for index in range(2):
await scheduling_send(actions, runs, "alice", team, member, str(index))
stored = await service.db("get", "alice", team["id"])
await service.finish("alice", stored, stored["jobs"][-1], "completed", "Retained evidence")
payload = {"team_id": team["id"], "limit": 1}
first = await service.search_results(payload, caller)
fingerprint = first["next_cursor"].split(":")[0]
legacy_cursor = f"{fingerprint}:{first['items'][0]['job_id']}"
expired = await service.search_results({**payload, "cursor": legacy_cursor}, caller)
assert expired["cursor_expired"] and expired["items"] == [] and expired["next_cursor"] is None
@pytest.mark.asyncio
async def test_history_cancelled_results_are_unavailable_before_and_after_admission(plugin):
_, actions, service = plugin
runs = Runs()
team = await create(actions, runs)
member = team["members"][0]
caller = context(runs, thread=member["thread_id"])
unavailable = {"available": False, "text": None, "truncated": None, "reason": "cancelled"}
for admitted in (False, True):
sent = await scheduling_send(actions, runs, "alice", team, member, str(admitted))
if admitted:
await service.tick()
assert sent["id"] in runs.runs
await actions["cancel"]({"team_id": team["id"], "job_id": sent["id"]}, context(runs))
if admitted:
await service.tick()
await service.tick()
result = await service.read_result({"team_id": team["id"], "job_id": sent["id"]}, caller)
assert result["status"] == "cancelled" and result["result"] == unavailable
found = await service.search_results({"team_id": team["id"]}, caller)
assert len(found["items"]) == 2 and all(item["result"] == unavailable for item in found["items"])
notice = "Request cancelled; checkpoint retained"
history = await service.read_history({"team_id": team["id"], "job_id": sent["id"]}, caller)
assert any(item["kind"] == "cancelled" and item["text"] == notice for item in history["items"])
stored = await service.db("get", "alice", team["id"])
assert stored["jobs"][-1]["result"] == notice
await service.db("change", "alice", team["id"], lambda t: t["jobs"][-1].pop("result"))
legacy = await service.read_result({"team_id": team["id"], "job_id": sent["id"]}, caller)
assert legacy["result"] == unavailable
filtered = await service.search_results({"team_id": team["id"], "query": notice}, caller)
assert filtered["items"] == []
def test_shared_state_budget_rejection_rolls_back_in_memory_mutations(plugin):
from deerflow_extension_agent_teams import shared_state
team = {
"id": "team_test",
"goal": "Goal",
"members": [{"id": "m1", "name": "Agent 1", "agent": "a1", "thread_id": "t1"}],
"jobs": [{"id": "j1", "thread_id": "t1", "status": "running", "input": {}}],
}
caller = SimpleNamespace(thread_id="t1")
for i in range(12):
shared_state.publish(team, {"team_id": "team_test", "request_id": f"req_{i}", "kind": "objective", "title": f"T{i}", "content": "A" * 950}, caller)
assert len(team["public_state"]["entries"]) == 12
assert team["public_state"]["revision"] == 12
with pytest.raises(ValueError, match="16,000 UTF-8 JSON bytes"):
shared_state.publish(team, {"team_id": "team_test", "request_id": "req_overflow", "kind": "objective", "title": "T_overflow", "content": "A" * 950}, caller)
assert len(team["public_state"]["entries"]) == 12
assert team["public_state"]["revision"] == 12
# Subsequent publish on the same dict succeeds after the rejection
res_pub = shared_state.publish(team, {"team_id": "team_test", "request_id": "req_small", "kind": "objective", "title": "T_small", "content": "small"}, caller)
assert res_pub["revision"] == 13
assert len(team["public_state"]["entries"]) == 13
entry_0 = team["public_state"]["entries"][0]
rev_before = entry_0["revision"]
title_before = entry_0["title"]
team["goal"] = "G" * 30000
update_payload = {"team_id": "team_test", "entry_id": entry_0["id"], "expected_revision": rev_before, "title": "New", "content": "New", "status": "active"}
with pytest.raises(ValueError, match="shared context budget"):
shared_state.update(team, update_payload, caller)
assert entry_0["title"] == title_before
assert entry_0["revision"] == rev_before
assert team["public_state"]["revision"] == 13
# Retrying the exact same update succeeds after resetting team["goal"]
team["goal"] = "Goal"
res_update = shared_state.update(team, update_payload, caller)
assert res_update["updated"] is True
assert res_update["conflict"] is False
assert entry_0["title"] == "New"
assert entry_0["revision"] == rev_before + 1