mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 14:00:35 +00:00
* fix(runtime): persist a rejected HITL call in the resume run's history An approval pause and its resolution are two Runs. The paused Run dispatches the gated call; the resume Run produces its result. An approved call executes and reaches on_tool_end, but a rejection is written by HITL after_model with no tool callback. Journal reconciliation only persists such middleware-written results for calls dispatched by the current Run, so the rejection stayed in checkpoint state and never reached the run-event history: /messages and the resume run's messages had no llm.tool.result for it. The worker already reads the durable interrupt payload to recover the gated tool names. It now also returns each paused tool_call_id with its name, and the resume journal adopts those calls, so the existing reconciliation persists the rejection under the resume Run. Approved results keep their tool-callback event and are deduplicated by message id; results from other earlier Runs are still not re-attributed. * fix(runtime): keep HITL replay names independent of call adoption Derive the replay-gate tool names and the adopted call ids separately from the matched pause's actions, so an action without a usable tool_call_id still gates replay. Pin that the all-reject escape paths (approval disabled, gated tools changed or cleared while paused) reach the history feed under the resume run, check resume-run attribution in the mixed-decision test, and add a CHANGELOG entry. * refactor(runtime): drop unused _pending_hitl_resume_calls wrapper --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
759 lines
34 KiB
Python
759 lines
34 KiB
Python
"""Gateway E2E for HITL tool approval (real app, scripted model)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
|
from langchain_core.messages import AIMessage, ToolMessage
|
|
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
from starlette.testclient import TestClient
|
|
from test_runtime_lifecycle_e2e import ( # noqa: F401
|
|
_create_thread,
|
|
_preserve_process_config_singletons,
|
|
_register_user,
|
|
_reset_process_singletons,
|
|
_run_body,
|
|
isolated_deer_flow_home,
|
|
)
|
|
|
|
from deerflow.config import get_app_config
|
|
|
|
pytestmark = pytest.mark.no_auto_user
|
|
|
|
_MODEL_PATCH = "deerflow.agents.lead_agent.agent.create_chat_model"
|
|
|
|
|
|
def _enable_approval():
|
|
from deerflow.config.tool_config import ToolConfig
|
|
|
|
cfg = get_app_config()
|
|
cfg.hitl_approval.enabled = True
|
|
cfg.hitl_approval.tools = ["write_file"]
|
|
if not any(tool.name == "write_file" for tool in cfg.tools):
|
|
cfg.tools.append(ToolConfig(name="write_file", use="deerflow.sandbox.tools:write_file_tool", group="file:write"))
|
|
|
|
|
|
class _StateAwareModel(FakeMessagesListChatModel):
|
|
call_count: int = 1
|
|
|
|
def bind_tools(self, tools: Any, **kwargs: Any) -> Any:
|
|
return self
|
|
|
|
def _generate(self, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any) -> ChatResult:
|
|
if any(isinstance(m, ToolMessage) for m in messages):
|
|
message = AIMessage(content="final answer")
|
|
else:
|
|
call_count = self.call_count
|
|
message = AIMessage(
|
|
content="",
|
|
tool_calls=[
|
|
{
|
|
"name": "write_file",
|
|
"args": {"path": f"/mnt/user-data/workspace/hitl-test-{index}.txt", "content": "ok"},
|
|
"id": f"call_hitl_{index}",
|
|
}
|
|
for index in range(1, call_count + 1)
|
|
],
|
|
)
|
|
return ChatResult(generations=[ChatGeneration(message=message)])
|
|
|
|
|
|
class _TwoStepStateAwareModel(_StateAwareModel):
|
|
reuse_tool_call_id: bool = False
|
|
|
|
def _generate(self, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any) -> ChatResult:
|
|
tool_message_count = sum(isinstance(message, ToolMessage) for message in messages)
|
|
if tool_message_count >= 2:
|
|
message = AIMessage(content="final answer")
|
|
else:
|
|
message = AIMessage(
|
|
content="",
|
|
tool_calls=[
|
|
{
|
|
"name": "write_file",
|
|
"args": {"path": f"/mnt/user-data/workspace/hitl-test-{tool_message_count + 1}.txt", "content": "ok"},
|
|
"id": "call_hitl_1" if self.reuse_tool_call_id else f"call_hitl_{tool_message_count + 1}",
|
|
}
|
|
],
|
|
)
|
|
return ChatResult(generations=[ChatGeneration(message=message)])
|
|
|
|
|
|
def _fake_two_step_model(*args, **kwargs):
|
|
return _TwoStepStateAwareModel(responses=[])
|
|
|
|
|
|
def _fake_reused_call_id_model(*args, **kwargs):
|
|
return _TwoStepStateAwareModel(responses=[], reuse_tool_call_id=True)
|
|
|
|
|
|
def _fake_model(*args, **kwargs):
|
|
return _StateAwareModel(responses=[])
|
|
|
|
|
|
def _fake_two_target_model(*args, **kwargs):
|
|
return _StateAwareModel(responses=[], call_count=2)
|
|
|
|
|
|
@pytest.fixture(params=["full", "delta"])
|
|
def isolated_app(request, isolated_deer_flow_home, monkeypatch): # noqa: F811
|
|
if request.param == "delta":
|
|
config_path = isolated_deer_flow_home.parent / "config.yaml"
|
|
config_path.write_text(
|
|
config_path.read_text(encoding="utf-8").replace(
|
|
"database:\n backend: sqlite\n",
|
|
"database:\n backend: sqlite\n checkpoint_channel_mode: delta\n",
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
_preserve_process_config_singletons(monkeypatch)
|
|
_reset_process_singletons(monkeypatch)
|
|
from deerflow.config import app_config as app_config_module
|
|
|
|
cfg = app_config_module.get_app_config()
|
|
cfg.database.sqlite_dir = str(isolated_deer_flow_home / "db")
|
|
from app.gateway.app import create_app
|
|
|
|
return create_app()
|
|
|
|
|
|
def _run(client, csrf, tid, body):
|
|
return client.post(f"/api/threads/{tid}/runs", json=body, headers={"X-CSRF-Token": csrf})
|
|
|
|
|
|
def _wait_for_status(client, tid, wanted, timeout=20.0):
|
|
deadline = time.monotonic() + timeout
|
|
state = {}
|
|
while time.monotonic() < deadline:
|
|
state = client.get(f"/api/threads/{tid}").json()
|
|
if state.get("status") == wanted:
|
|
return state
|
|
time.sleep(0.2)
|
|
raise AssertionError(f"thread did not reach {wanted}: {state}")
|
|
|
|
|
|
def _wait_for_approval(client, tid, timeout=20.0):
|
|
deadline = time.monotonic() + timeout
|
|
state = {}
|
|
while time.monotonic() < deadline:
|
|
state = client.get(f"/api/threads/{tid}").json()
|
|
payloads = [item for group in state.get("interrupts", {}).values() for item in group]
|
|
if payloads and payloads[0].get("value", {}).get("kind") == "hitl_approval":
|
|
return state
|
|
time.sleep(0.2)
|
|
raise AssertionError(f"thread did not expose a HITL approval: {state}")
|
|
|
|
|
|
def _wait_run_status(client, tid, rid, wanted, timeout=20.0):
|
|
deadline = time.monotonic() + timeout
|
|
run = {}
|
|
while time.monotonic() < deadline and run.get("status") != wanted:
|
|
run = client.get(f"/api/threads/{tid}/runs/{rid}").json()
|
|
time.sleep(0.2)
|
|
return run
|
|
|
|
|
|
def _wait_run_terminal(client, tid, rid, timeout=20.0):
|
|
deadline = time.monotonic() + timeout
|
|
run = {}
|
|
while time.monotonic() < deadline:
|
|
run = client.get(f"/api/threads/{tid}/runs/{rid}").json()
|
|
if run.get("status") not in ("running", "pending", "queued"):
|
|
return run
|
|
time.sleep(0.2)
|
|
raise AssertionError(f"run did not finish: {run}")
|
|
|
|
|
|
def _approval_request_id(client, tid, tool_call_id):
|
|
state = _wait_for_approval(client, tid)
|
|
payload = next(item for group in state["interrupts"].values() for item in group)["value"]
|
|
return next(action["request_id"] for action in payload["actions"] if action["tool_call_id"] == tool_call_id)
|
|
|
|
|
|
def _resume(client, csrf, tid, option_id, value=None, *, tool_call_id="call_hitl_1", request_id=None):
|
|
response = {
|
|
"option_id": option_id,
|
|
"tool_call_id": tool_call_id,
|
|
"request_id": request_id or _approval_request_id(client, tid, tool_call_id),
|
|
}
|
|
if value is not None:
|
|
response["value"] = value
|
|
return _run(client, csrf, tid, {"command": {"resume": {"responses": [response]}}})
|
|
|
|
|
|
def _feed_tool_result_ids(client, tid):
|
|
rows = client.get(f"/api/threads/{tid}/messages").json()
|
|
return [row["content"]["id"] for row in rows if row.get("event_type") == "llm.tool.result"]
|
|
|
|
|
|
def _run_tool_result_call_ids(client, tid, run_id):
|
|
rows = client.get(f"/api/threads/{tid}/runs/{run_id}/messages").json()
|
|
rows = rows["data"] if isinstance(rows, dict) else rows
|
|
return [row["content"]["tool_call_id"] for row in rows if row.get("event_type") == "llm.tool.result"]
|
|
|
|
|
|
def test_approval_pause_then_approve_executes(isolated_app):
|
|
_enable_approval()
|
|
with patch(_MODEL_PATCH, new=_fake_model), TestClient(isolated_app) as client:
|
|
csrf = _register_user(client)
|
|
tid = _create_thread(client, csrf)
|
|
rid = _run(client, csrf, tid, _run_body(input={"messages": [{"role": "user", "content": "write"}]})).json()["run_id"]
|
|
state = _wait_for_approval(client, tid)
|
|
deadline = time.monotonic() + 20.0
|
|
run = {}
|
|
while time.monotonic() < deadline and run.get("status") != "waiting_for_input":
|
|
run = client.get(f"/api/threads/{tid}/runs/{rid}").json()
|
|
time.sleep(0.2)
|
|
assert run["status"] == "waiting_for_input"
|
|
payloads = [i for group in state["interrupts"].values() for i in group]
|
|
assert payloads and payloads[0]["value"]["kind"] == "hitl_approval"
|
|
assert payloads[0]["value"]["pending_tool_call_ids"] == ["call_hitl_1"]
|
|
|
|
conflict = _run(client, csrf, tid, _run_body(input={"messages": [{"role": "user", "content": "other"}]}))
|
|
assert conflict.status_code == 409
|
|
assert conflict.json()["detail"]["code"] == "approval_pending"
|
|
|
|
approved = _resume(client, csrf, tid, "approve")
|
|
assert approved.status_code == 200, approved.text
|
|
_ = _wait_run_terminal(client, tid, approved.json()["run_id"])
|
|
state = client.get(f"/api/threads/{tid}").json()
|
|
contents = [m.get("content") for m in state["values"]["messages"] if m.get("type") == "tool"]
|
|
assert "OK" in contents
|
|
assert state["status"] == "idle"
|
|
|
|
duplicate = _resume(client, csrf, tid, "approve", request_id=payloads[0]["value"]["request_id"])
|
|
assert duplicate.status_code == 409
|
|
assert duplicate.json()["detail"]["code"] == "no_pending_approval"
|
|
|
|
|
|
def test_fresh_run_rechecks_pending_approval_at_durable_admission(isolated_app):
|
|
"""A pause created after the early guard still blocks fresh admission."""
|
|
_enable_approval()
|
|
model_started = threading.Event()
|
|
release_model = threading.Event()
|
|
admission_started = threading.Event()
|
|
release_admission = threading.Event()
|
|
|
|
class _GatedModel(_StateAwareModel):
|
|
def _generate(self, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any) -> ChatResult:
|
|
model_started.set()
|
|
if not release_model.wait(20):
|
|
raise TimeoutError("test did not release the model")
|
|
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
|
|
|
def _gated_model(*args, **kwargs):
|
|
return _GatedModel(responses=[])
|
|
|
|
from app.gateway import services
|
|
|
|
original_resolve = services.resolve_project_context
|
|
|
|
async def _gate_fresh_admission(*args, **kwargs):
|
|
if model_started.is_set() and not admission_started.is_set():
|
|
admission_started.set()
|
|
if not await asyncio.to_thread(release_admission.wait, 20):
|
|
raise TimeoutError("test did not release fresh admission")
|
|
return await original_resolve(*args, **kwargs)
|
|
|
|
with (
|
|
patch(_MODEL_PATCH, new=_gated_model),
|
|
patch("app.gateway.services.resolve_project_context", new=_gate_fresh_admission),
|
|
TestClient(isolated_app) as client,
|
|
):
|
|
csrf = _register_user(client)
|
|
tid = _create_thread(client, csrf)
|
|
initial = _run(client, csrf, tid, _run_body(input={"messages": [{"role": "user", "content": "write"}]}))
|
|
assert initial.status_code == 200, initial.text
|
|
assert model_started.wait(20)
|
|
|
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
fresh = pool.submit(_run, client, csrf, tid, _run_body(input={"messages": [{"role": "user", "content": "replace"}]}))
|
|
try:
|
|
# The fresh request has passed its early check but not durable
|
|
# admission. Only now let the other run expose its pause.
|
|
assert admission_started.wait(20)
|
|
release_model.set()
|
|
_wait_for_approval(client, tid)
|
|
release_admission.set()
|
|
conflict = fresh.result(timeout=20)
|
|
finally:
|
|
release_model.set()
|
|
release_admission.set()
|
|
|
|
assert conflict.status_code == 409, conflict.text
|
|
assert conflict.json()["detail"]["code"] == "approval_pending"
|
|
assert _wait_run_status(client, tid, initial.json()["run_id"], "waiting_for_input")["status"] == "waiting_for_input"
|
|
|
|
cleanup = _resume(client, csrf, tid, "reject")
|
|
assert cleanup.status_code == 200, cleanup.text
|
|
_wait_run_terminal(client, tid, cleanup.json()["run_id"])
|
|
|
|
|
|
def test_approval_reject_returns_reason_without_execution(isolated_app):
|
|
_enable_approval()
|
|
with patch(_MODEL_PATCH, new=_fake_model), TestClient(isolated_app) as client:
|
|
csrf = _register_user(client)
|
|
tid = _create_thread(client, csrf)
|
|
_run(client, csrf, tid, _run_body(input={"messages": [{"role": "user", "content": "write"}]}))
|
|
_wait_for_approval(client, tid)
|
|
rejected = _resume(client, csrf, tid, "reject", value="do not write")
|
|
assert rejected.status_code == 200, rejected.text
|
|
_wait_run_terminal(client, tid, rejected.json()["run_id"])
|
|
state = client.get(f"/api/threads/{tid}").json()
|
|
contents = [m.get("content") for m in state["values"]["messages"] if m.get("type") == "tool"]
|
|
assert any("do not write" in str(c) for c in contents)
|
|
assert "OK" not in contents
|
|
|
|
|
|
def test_invalid_resume_is_422_and_does_not_create_run(isolated_app):
|
|
_enable_approval()
|
|
with patch(_MODEL_PATCH, new=_fake_model), TestClient(isolated_app) as client:
|
|
csrf = _register_user(client)
|
|
tid = _create_thread(client, csrf)
|
|
rid = _run(client, csrf, tid, _run_body(input={"messages": [{"role": "user", "content": "write"}]})).json()["run_id"]
|
|
_wait_run_status(client, tid, rid, "waiting_for_input")
|
|
# Wrong id → 422 without admitting a run; a valid approval still works.
|
|
bad = _run(client, csrf, tid, {"command": {"resume": {"responses": [{"option_id": "approve", "tool_call_id": "wrong", "request_id": "unused"}]}}})
|
|
assert bad.status_code == 422, bad.text
|
|
assert bad.json()["detail"]["code"] == "invalid_approval_response"
|
|
assert {run["run_id"] for run in client.get(f"/api/threads/{tid}/runs").json()} == {rid}
|
|
good = _resume(client, csrf, tid, "approve")
|
|
assert good.status_code == 200, good.text
|
|
|
|
|
|
def test_stale_request_id_cannot_approve_reused_tool_call(isolated_app):
|
|
_enable_approval()
|
|
with patch(_MODEL_PATCH, new=_fake_reused_call_id_model), TestClient(isolated_app) as client:
|
|
csrf = _register_user(client)
|
|
tid = _create_thread(client, csrf)
|
|
first_run_id = _run(client, csrf, tid, _run_body(input={"messages": [{"role": "user", "content": "write twice"}]})).json()["run_id"]
|
|
first_pause = _wait_for_approval(client, tid)
|
|
first_payload = next(item for group in first_pause["interrupts"].values() for item in group)["value"]
|
|
first_request_id = first_payload["request_id"]
|
|
_wait_run_status(client, tid, first_run_id, "waiting_for_input")
|
|
|
|
rejected = _resume(client, csrf, tid, "reject", value="reject the first arguments")
|
|
assert rejected.status_code == 200, rejected.text
|
|
second_run_id = rejected.json()["run_id"]
|
|
_wait_run_status(client, tid, second_run_id, "waiting_for_input")
|
|
second_pause = _wait_for_approval(client, tid)
|
|
second_payload = next(item for group in second_pause["interrupts"].values() for item in group)["value"]
|
|
assert second_payload["pending_tool_call_ids"] == first_payload["pending_tool_call_ids"] == ["call_hitl_1"]
|
|
assert second_payload["request_id"] != first_request_id
|
|
|
|
stale = _run(
|
|
client,
|
|
csrf,
|
|
tid,
|
|
{"command": {"resume": {"responses": [{"option_id": "approve", "tool_call_id": "call_hitl_1", "request_id": first_request_id}]}}},
|
|
)
|
|
assert stale.status_code == 422, stale.text
|
|
assert stale.json()["detail"]["code"] == "invalid_approval_response"
|
|
assert {run["run_id"] for run in client.get(f"/api/threads/{tid}/runs").json()} == {first_run_id, second_run_id}
|
|
state = client.get(f"/api/threads/{tid}").json()
|
|
tool_messages = [message for message in state["values"]["messages"] if message.get("type") == "tool"]
|
|
assert len(tool_messages) == 1
|
|
assert "OK" not in [message.get("content") for message in tool_messages]
|
|
|
|
approved = _resume(client, csrf, tid, "approve")
|
|
assert approved.status_code == 200, approved.text
|
|
terminal = _wait_run_terminal(client, tid, approved.json()["run_id"])
|
|
assert terminal["status"] == "success"
|
|
state = client.get(f"/api/threads/{tid}").json()
|
|
tool_messages = [message for message in state["values"]["messages"] if message.get("type") == "tool"]
|
|
assert len(tool_messages) == 2
|
|
assert sum(message.get("content") == "OK" for message in tool_messages) == 1
|
|
|
|
|
|
def test_duplicate_resume_ids_are_422_without_creating_a_run(isolated_app):
|
|
_enable_approval()
|
|
with patch(_MODEL_PATCH, new=_fake_two_target_model), TestClient(isolated_app) as client:
|
|
csrf = _register_user(client)
|
|
tid = _create_thread(client, csrf)
|
|
rid = _run(client, csrf, tid, _run_body(input={"messages": [{"role": "user", "content": "write twice"}]})).json()["run_id"]
|
|
_wait_run_status(client, tid, rid, "waiting_for_input")
|
|
duplicate = _run(
|
|
client,
|
|
csrf,
|
|
tid,
|
|
{
|
|
"command": {
|
|
"resume": {
|
|
"responses": [
|
|
{"option_id": "reject", "tool_call_id": "call_hitl_1", "request_id": _approval_request_id(client, tid, "call_hitl_1")},
|
|
{"option_id": "reject", "tool_call_id": "call_hitl_1", "request_id": _approval_request_id(client, tid, "call_hitl_1")},
|
|
]
|
|
}
|
|
}
|
|
},
|
|
)
|
|
assert duplicate.status_code == 422, duplicate.text
|
|
assert duplicate.json()["detail"]["code"] == "invalid_approval_response"
|
|
assert {run["run_id"] for run in client.get(f"/api/threads/{tid}/runs").json()} == {rid}
|
|
|
|
mixed = _run(
|
|
client,
|
|
csrf,
|
|
tid,
|
|
{
|
|
"command": {
|
|
"resume": {
|
|
"responses": [
|
|
{"option_id": "approve", "tool_call_id": "call_hitl_1", "request_id": _approval_request_id(client, tid, "call_hitl_1")},
|
|
{"option_id": "reject", "tool_call_id": "call_hitl_2", "value": "second rejected", "request_id": _approval_request_id(client, tid, "call_hitl_2")},
|
|
]
|
|
}
|
|
}
|
|
},
|
|
)
|
|
assert mixed.status_code == 200, mixed.text
|
|
terminal = _wait_run_terminal(client, tid, mixed.json()["run_id"])
|
|
assert terminal["status"] == "success"
|
|
state = client.get(f"/api/threads/{tid}").json()
|
|
tool_messages = [message for message in state["values"]["messages"] if message.get("type") == "tool"]
|
|
assert sum(message.get("content") == "OK" for message in tool_messages) == 1
|
|
assert any("second rejected" in str(message.get("content")) for message in tool_messages)
|
|
|
|
|
|
def test_disabled_resume_409_and_all_reject_escape(isolated_app):
|
|
_enable_approval()
|
|
with patch(_MODEL_PATCH, new=_fake_two_step_model), TestClient(isolated_app) as client:
|
|
csrf = _register_user(client)
|
|
tid = _create_thread(client, csrf)
|
|
_run(client, csrf, tid, _run_body(input={"messages": [{"role": "user", "content": "write"}]}))
|
|
_wait_for_approval(client, tid)
|
|
get_app_config().hitl_approval.enabled = False
|
|
disabled = _resume(client, csrf, tid, "approve")
|
|
assert disabled.status_code == 409
|
|
assert disabled.json()["detail"]["code"] == "hitl_approval_disabled"
|
|
escape = _resume(client, csrf, tid, "reject")
|
|
assert escape.status_code == 200, escape.text
|
|
terminal = _wait_run_terminal(client, tid, escape.json()["run_id"])
|
|
assert terminal["status"] == "success"
|
|
assert _wait_for_status(client, tid, "idle")["status"] == "idle"
|
|
state_tools = [m for m in client.get(f"/api/threads/{tid}").json()["values"]["messages"] if m.get("type") == "tool"]
|
|
contents = [m.get("content") for m in state_tools]
|
|
assert "OK" not in contents
|
|
assert len(contents) == 2
|
|
# The escape run adopts the paused call, so its denial reaches the history feed.
|
|
assert _feed_tool_result_ids(client, tid) == [m["id"] for m in state_tools]
|
|
assert "call_hitl_1" in _run_tool_result_call_ids(client, tid, escape.json()["run_id"])
|
|
|
|
|
|
@pytest.mark.parametrize("current_tools", [["bash"], []], ids=["changed", "cleared"])
|
|
def test_changed_tools_all_reject_escape_preserves_later_gate(isolated_app, current_tools):
|
|
_enable_approval()
|
|
with patch(_MODEL_PATCH, new=_fake_two_step_model), TestClient(isolated_app) as client:
|
|
csrf = _register_user(client)
|
|
tid = _create_thread(client, csrf)
|
|
_run(client, csrf, tid, _run_body(input={"messages": [{"role": "user", "content": "write"}]}))
|
|
_wait_for_approval(client, tid)
|
|
get_app_config().hitl_approval.tools = current_tools
|
|
|
|
escape = _resume(client, csrf, tid, "reject")
|
|
assert escape.status_code == 200, escape.text
|
|
second_run_id = escape.json()["run_id"]
|
|
second_pause = _wait_run_status(client, tid, second_run_id, "waiting_for_input")
|
|
assert second_pause["status"] == "waiting_for_input"
|
|
state = _wait_for_approval(client, tid)
|
|
payloads = [item for group in state["interrupts"].values() for item in group]
|
|
assert payloads[0]["value"]["pending_tool_call_ids"] == ["call_hitl_2"]
|
|
tool_messages = [message for message in state["values"]["messages"] if message.get("type") == "tool"]
|
|
assert len(tool_messages) == 1
|
|
assert tool_messages[0]["tool_call_id"] == "call_hitl_1"
|
|
assert tool_messages[0]["status"] == "error"
|
|
assert "OK" not in [message.get("content") for message in tool_messages]
|
|
assert _run_tool_result_call_ids(client, tid, second_run_id) == ["call_hitl_1"]
|
|
|
|
second_escape = _resume(client, csrf, tid, "reject", tool_call_id="call_hitl_2")
|
|
assert second_escape.status_code == 200, second_escape.text
|
|
terminal = _wait_run_terminal(client, tid, second_escape.json()["run_id"])
|
|
assert terminal["status"] == "success"
|
|
assert _wait_for_status(client, tid, "idle")["status"] == "idle"
|
|
tool_messages = [message for message in client.get(f"/api/threads/{tid}").json()["values"]["messages"] if message.get("type") == "tool"]
|
|
assert len(tool_messages) == 2
|
|
assert all(message.get("status") == "error" for message in tool_messages)
|
|
assert "OK" not in [message.get("content") for message in tool_messages]
|
|
assert _feed_tool_result_ids(client, tid) == [message["id"] for message in tool_messages]
|
|
assert _run_tool_result_call_ids(client, tid, second_escape.json()["run_id"]) == ["call_hitl_2"]
|
|
|
|
|
|
def test_stale_all_reject_cannot_dismiss_newer_pause(isolated_app):
|
|
"""Checkpoint-only recovery is limited to the pause that was validated."""
|
|
_enable_approval()
|
|
with patch(_MODEL_PATCH, new=_fake_two_step_model), TestClient(isolated_app) as client:
|
|
csrf = _register_user(client)
|
|
tid = _create_thread(client, csrf)
|
|
initial = _run(client, csrf, tid, _run_body(input={"messages": [{"role": "user", "content": "write twice"}]}))
|
|
assert initial.status_code == 200, initial.text
|
|
_wait_for_approval(client, tid)
|
|
first_request_id = _approval_request_id(client, tid, "call_hitl_1")
|
|
|
|
reject_validated = threading.Event()
|
|
release_reject = threading.Event()
|
|
from app.gateway import services
|
|
|
|
original_resolve = services.resolve_project_context
|
|
|
|
async def _queue_reject_before_admission(*args, **kwargs):
|
|
if not reject_validated.is_set():
|
|
# resolve_project_context runs after resume validation but before
|
|
# the per-thread admission lock, so P1 can still be approved.
|
|
reject_validated.set()
|
|
if not await asyncio.to_thread(release_reject.wait, 20):
|
|
raise TimeoutError("test did not release stale rejection")
|
|
return await original_resolve(*args, **kwargs)
|
|
|
|
with (
|
|
patch("app.gateway.services.resolve_project_context", new=_queue_reject_before_admission),
|
|
ThreadPoolExecutor(max_workers=1) as pool,
|
|
):
|
|
stale_reject = pool.submit(_resume, client, csrf, tid, "reject", tool_call_id="call_hitl_1", request_id=first_request_id)
|
|
try:
|
|
assert reject_validated.wait(20)
|
|
approved = _resume(client, csrf, tid, "approve", request_id=first_request_id)
|
|
assert approved.status_code == 200, approved.text
|
|
second_run_id = approved.json()["run_id"]
|
|
assert _wait_run_status(client, tid, second_run_id, "waiting_for_input")["status"] == "waiting_for_input"
|
|
second_request_id = _approval_request_id(client, tid, "call_hitl_2")
|
|
assert second_request_id != first_request_id
|
|
|
|
release_reject.set()
|
|
conflict = stale_reject.result(timeout=20)
|
|
finally:
|
|
release_reject.set()
|
|
|
|
assert conflict.status_code == 409, conflict.text
|
|
assert conflict.json()["detail"]["code"] == "no_pending_approval"
|
|
assert client.get(f"/api/threads/{tid}/runs/{second_run_id}").json()["status"] == "waiting_for_input"
|
|
state = _wait_for_approval(client, tid)
|
|
payloads = [item for group in state["interrupts"].values() for item in group]
|
|
assert payloads[0]["value"]["pending_tool_call_ids"] == ["call_hitl_2"]
|
|
|
|
cleanup = _resume(client, csrf, tid, "reject", tool_call_id="call_hitl_2", request_id=second_request_id)
|
|
assert cleanup.status_code == 200, cleanup.text
|
|
_wait_run_terminal(client, tid, cleanup.json()["run_id"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generic_interrupt_resume_admission_survives_head_moving_state_update(monkeypatch):
|
|
from langchain_core.messages import AIMessage, HumanMessage
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.graph import MessagesState, StateGraph
|
|
from langgraph.types import interrupt
|
|
|
|
from app.gateway.hitl_approval import validate_resume_request
|
|
|
|
class EmptyRunStore:
|
|
async def list_by_thread(self, thread_id, limit):
|
|
return []
|
|
|
|
monkeypatch.setattr("app.gateway.deps.get_run_store", lambda request: EmptyRunStore())
|
|
|
|
def answer(state):
|
|
value = interrupt({"question": "Continue?"})
|
|
return {"messages": [AIMessage(content=f"answer:{value}")]}
|
|
|
|
builder = StateGraph(MessagesState)
|
|
builder.add_node("answer", answer)
|
|
builder.set_entry_point("answer")
|
|
builder.set_finish_point("answer")
|
|
saver = InMemorySaver()
|
|
graph = builder.compile(checkpointer=saver)
|
|
config = {"configurable": {"thread_id": "generic-head-move"}}
|
|
await graph.ainvoke({"messages": [HumanMessage(content="go")]}, config)
|
|
await graph.aupdate_state(config, {"messages": [AIMessage(content="head move")]}, as_node="answer")
|
|
accessor = SimpleNamespace(aget=graph.aget_state, checkpointer=saver)
|
|
assert not (await graph.aget_state(config)).next
|
|
|
|
body = SimpleNamespace(command={"resume": "yes"})
|
|
await validate_resume_request(
|
|
request=SimpleNamespace(),
|
|
thread_id="generic-head-move",
|
|
accessor=accessor,
|
|
read_config=config,
|
|
enabled=False,
|
|
body=body,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_approval_answered_preserves_idempotency_fields(monkeypatch):
|
|
from app.gateway.hitl_approval import mark_approval_answered
|
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
|
|
|
identity = {"version": 1, "kind": "resume", "sha256": "a" * 64}
|
|
store = MemoryRunStore()
|
|
await store.put(
|
|
"paused-run",
|
|
thread_id="thread-1",
|
|
status="waiting_for_input",
|
|
metadata={"hitl_approval": {"kind": "hitl_approval", "request_id": "approval-1"}, "retained": True},
|
|
idempotency_key="http-run:key",
|
|
idempotency_request=identity,
|
|
)
|
|
monkeypatch.setattr("app.gateway.deps.get_run_store", lambda request: store)
|
|
|
|
await mark_approval_answered(SimpleNamespace(), "thread-1")
|
|
|
|
answered = await store.get("paused-run")
|
|
assert answered is not None
|
|
assert answered["status"] == "success"
|
|
assert answered["idempotency_key"] == "http-run:key"
|
|
assert answered["idempotency_request"] == identity
|
|
assert answered["metadata"] == {"retained": True}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_store_fallback_uses_original_langgraph_task_id(monkeypatch):
|
|
from langchain_core.messages import AIMessage, HumanMessage
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.graph import MessagesState, StateGraph
|
|
from langgraph.types import interrupt
|
|
|
|
from app.gateway.hitl_approval import find_pending_interrupts
|
|
|
|
payload = {
|
|
"kind": "hitl_approval",
|
|
"version": 1,
|
|
"request_id": "approval-1",
|
|
"actions": [],
|
|
"allowed_decisions": ["approve", "reject"],
|
|
"pending_tool_call_ids": ["call-1"],
|
|
}
|
|
|
|
class WaitingRunStore:
|
|
async def list_by_thread(self, thread_id, limit):
|
|
return [{"run_id": "paused-run", "status": "waiting_for_input", "metadata": {"hitl_approval": payload}}]
|
|
|
|
monkeypatch.setattr("app.gateway.deps.get_run_store", lambda request: WaitingRunStore())
|
|
|
|
def approval(state):
|
|
value = interrupt(payload)
|
|
return {"messages": [AIMessage(content=f"answer:{value}")]}
|
|
|
|
builder = StateGraph(MessagesState)
|
|
builder.add_node("approval", approval)
|
|
builder.set_entry_point("approval")
|
|
builder.set_finish_point("approval")
|
|
saver = InMemorySaver()
|
|
graph = builder.compile(checkpointer=saver)
|
|
config = {"configurable": {"thread_id": "approval-head-move"}}
|
|
await graph.ainvoke({"messages": [HumanMessage(content="go")]}, config)
|
|
paused = await graph.aget_state(config)
|
|
paused_task = next(task for task in paused.tasks if task.interrupts)
|
|
original_interrupt = paused_task.interrupts[0]
|
|
await graph.aupdate_state(config, {"messages": [AIMessage(content="head move")]}, as_node="approval")
|
|
accessor = SimpleNamespace(aget=graph.aget_state, checkpointer=saver)
|
|
assert not (await graph.aget_state(config)).next
|
|
|
|
pending = await find_pending_interrupts(SimpleNamespace(), "approval-head-move", accessor=accessor, read_config=config)
|
|
|
|
assert set(pending) == {paused_task.id}
|
|
assert pending[paused_task.id][0].value == payload
|
|
assert pending[paused_task.id][0].id == original_interrupt.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_store_fallback_reconstructs_interrupt_without_checkpoint(monkeypatch):
|
|
from app.gateway.hitl_approval import find_pending_interrupts
|
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
|
|
|
payload = {
|
|
"kind": "hitl_approval",
|
|
"request_id": "approval-store-only",
|
|
"pending_tool_call_ids": ["call-1"],
|
|
}
|
|
store = MemoryRunStore()
|
|
await store.put(
|
|
"paused-run",
|
|
thread_id="thread-store-only",
|
|
status="waiting_for_input",
|
|
metadata={"hitl_approval": payload},
|
|
)
|
|
monkeypatch.setattr("app.gateway.deps.get_run_store", lambda request: store)
|
|
|
|
pending = await find_pending_interrupts(SimpleNamespace(), "thread-store-only")
|
|
|
|
assert set(pending) == {"paused-run"}
|
|
assert len(pending["paused-run"]) == 1
|
|
assert pending["paused-run"][0].value == payload
|
|
assert pending["paused-run"][0].id == "approval-store-only"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_newest_resume_write_subtracts_answered_checkpoint_pause():
|
|
from langchain_core.messages import HumanMessage
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.graph import MessagesState, StateGraph
|
|
from langgraph.types import Command, interrupt
|
|
|
|
from app.gateway.hitl_approval import has_any_pending_interrupt
|
|
|
|
def answer(state):
|
|
value = interrupt({"question": "Continue?"})
|
|
return {"messages": [AIMessage(content=f"answer:{value}")]}
|
|
|
|
builder = StateGraph(MessagesState)
|
|
builder.add_node("answer", answer)
|
|
builder.set_entry_point("answer")
|
|
builder.set_finish_point("answer")
|
|
saver = InMemorySaver()
|
|
graph = builder.compile(checkpointer=saver)
|
|
config = {"configurable": {"thread_id": "answered-pause"}}
|
|
await graph.ainvoke({"messages": [HumanMessage(content="go")]}, config)
|
|
accessor = SimpleNamespace(checkpointer=saver)
|
|
|
|
assert await has_any_pending_interrupt(accessor, config) is True
|
|
await graph.ainvoke(Command(resume="yes"), config)
|
|
assert not (await graph.aget_state(config)).next
|
|
assert await has_any_pending_interrupt(accessor, config) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pending_interrupts_fall_back_to_sync_saver_history():
|
|
from langchain_core.messages import HumanMessage
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.graph import MessagesState, StateGraph
|
|
from langgraph.types import interrupt
|
|
|
|
from app.gateway.hitl_approval import _interrupt_from_checkpoint_history, _pending_interrupts_from_checkpoint_history, has_any_pending_interrupt
|
|
|
|
class SyncOnlySaver(InMemorySaver):
|
|
def alist(self, *args, **kwargs):
|
|
raise NotImplementedError
|
|
|
|
payload = {"question": "Continue?"}
|
|
|
|
def answer(state):
|
|
value = interrupt(payload)
|
|
return {"messages": [AIMessage(content=f"answer:{value}")]}
|
|
|
|
builder = StateGraph(MessagesState)
|
|
builder.add_node("answer", answer)
|
|
builder.set_entry_point("answer")
|
|
builder.set_finish_point("answer")
|
|
saver = SyncOnlySaver()
|
|
graph = builder.compile(checkpointer=saver)
|
|
config = {"configurable": {"thread_id": "sync-saver-pause"}}
|
|
await graph.ainvoke({"messages": [HumanMessage(content="go")]}, config)
|
|
|
|
pending = await _pending_interrupts_from_checkpoint_history(saver, config)
|
|
recovered = await _interrupt_from_checkpoint_history(SimpleNamespace(checkpointer=saver), config, payload)
|
|
|
|
assert len(pending) == 1
|
|
assert next(iter(pending.values()))[0].value["question"] == "Continue?"
|
|
assert recovered is not None
|
|
recovered_task_id, recovered_interrupt = recovered
|
|
assert recovered_task_id in pending
|
|
assert recovered_interrupt.value == payload
|
|
assert await has_any_pending_interrupt(SimpleNamespace(checkpointer=saver), config) is True
|