mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 17:06:05 +00:00
* feat(frontend): pin recent chats * fix(threads): address pin-chat review feedback - Stop bumping updated_at on metadata-only PATCH (pin/unpin) via a new update_metadata(touch=False) path so unpinning no longer jumps a chat to the top of the updated_at-sorted recent list. - Narrow patchThreadMetadata to a ThreadMetadataPatchResponse matching the Gateway's actual response (no values/context). - Namespace the pinned metadata key as deerflow_pinned for consistency with deerflow_sidecar / deerflow_branch. - Cover touch/touch=False behavior in repo + router tests; document the e2e mock's updated_at preservation now mirrors production. * style(frontend): format thread utils test * fix(threads): make pinned ordering server-side * test(frontend): keep infinite-scroll fixture order stable * test(frontend): stabilize lark reconnect e2e * docs: clarify thread pin metadata contract
2699 lines
108 KiB
Python
2699 lines
108 KiB
Python
import asyncio
|
|
import re
|
|
from contextlib import asynccontextmanager
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from _router_auth_helpers import make_authed_test_app
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.testclient import TestClient
|
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
from langgraph.checkpoint.base import empty_checkpoint, uuid6
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.store.memory import InMemoryStore
|
|
from langgraph.types import Overwrite
|
|
|
|
from app.gateway import services as gateway_services
|
|
from app.gateway.routers import thread_runs, threads
|
|
from deerflow.config.paths import Paths
|
|
from deerflow.persistence.thread_meta import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError
|
|
from deerflow.persistence.thread_meta.memory import THREADS_NS, MemoryThreadMetaStore
|
|
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
|
|
|
|
_ISO_TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
|
|
|
|
|
|
class _PermissiveThreadMetaStore(MemoryThreadMetaStore):
|
|
"""Memory store that skips user-id filtering for router tests.
|
|
|
|
Owner isolation is exercised separately in
|
|
``test_memory_thread_meta_isolation.py``. Router tests need to drive
|
|
the FastAPI surface end-to-end with a single fixed app user, but the
|
|
stub auth middleware in ``_router_auth_helpers`` stamps a fresh UUID
|
|
on every request, so the production filtering would reject every
|
|
pre-seeded record. Bypass that filter so the test can focus on the
|
|
timestamp wire format.
|
|
"""
|
|
|
|
async def _get_owned_record(self, thread_id, user_id, method_name): # type: ignore[override]
|
|
item = await self._store.aget(THREADS_NS, thread_id)
|
|
return dict(item.value) if item is not None else None
|
|
|
|
async def check_access(self, thread_id, user_id, *, require_existing=False): # type: ignore[override]
|
|
item = await self._store.aget(THREADS_NS, thread_id)
|
|
if item is None:
|
|
return not require_existing
|
|
return True
|
|
|
|
async def create(self, thread_id, *, assistant_id=None, user_id=None, display_name=None, metadata=None): # type: ignore[override]
|
|
return await super().create(thread_id, assistant_id=assistant_id, user_id=None, display_name=display_name, metadata=metadata)
|
|
|
|
async def search(self, *, metadata=None, status=None, limit=100, offset=0, user_id=None): # type: ignore[override]
|
|
return await super().search(metadata=metadata, status=status, limit=limit, offset=offset, user_id=None)
|
|
|
|
|
|
class _ThreadTestRunManager:
|
|
async def list_by_thread(self, _thread_id: str, *, user_id=None, limit: int = 100) -> list:
|
|
return []
|
|
|
|
@asynccontextmanager
|
|
async def reserve_thread_operation(self, _thread_id: str, **_kwargs):
|
|
yield
|
|
|
|
|
|
def _build_thread_app() -> tuple[FastAPI, InMemoryStore, InMemorySaver]:
|
|
"""Build a stub-authed FastAPI app wired with an in-memory ThreadMetaStore.
|
|
|
|
The thread_store on ``app.state`` is a permissive subclass of
|
|
``MemoryThreadMetaStore`` so tests can drive ``/api/threads``
|
|
end-to-end and pre-seed legacy records via the underlying BaseStore.
|
|
|
|
Returns ``(app, store, checkpointer)`` for direct seeding/inspection.
|
|
"""
|
|
app = make_authed_test_app()
|
|
store = InMemoryStore()
|
|
checkpointer = InMemorySaver()
|
|
app.state.store = store
|
|
app.state.checkpointer = checkpointer
|
|
app.state.run_manager = _ThreadTestRunManager()
|
|
app.state.thread_store = _PermissiveThreadMetaStore(store)
|
|
app.include_router(threads.router)
|
|
return app, store, checkpointer
|
|
|
|
|
|
def test_compact_rejects_run_owned_by_another_worker(monkeypatch) -> None:
|
|
"""The HTTP guard must consult the shared store, not only local run memory."""
|
|
from deerflow.runtime import RunManager, RunStatus
|
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
|
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
run_store = MemoryRunStore()
|
|
owner = RunManager(store=run_store, worker_id="worker-a")
|
|
non_owner = RunManager(store=run_store, worker_id="worker-b")
|
|
app.state.run_manager = non_owner
|
|
monkeypatch.setattr(
|
|
threads,
|
|
"build_checkpoint_state_mutation_accessor",
|
|
lambda *_args, **_kwargs: (SimpleNamespace(), None),
|
|
)
|
|
|
|
async def _seed_active_run() -> None:
|
|
active = await owner.create_or_reject("thread-compact-race")
|
|
await owner.set_status(active.run_id, RunStatus.running)
|
|
|
|
asyncio.run(_seed_active_run())
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post("/api/threads", json={"thread_id": "thread-compact-race"})
|
|
assert created.status_code == 200, created.text
|
|
response = client.post("/api/threads/thread-compact-race/compact", json={"force": True})
|
|
|
|
assert response.status_code == 409
|
|
assert response.json()["detail"] == "Thread has a run in flight. Compact after the run finishes."
|
|
|
|
|
|
def test_update_state_rejects_run_owned_by_another_worker(monkeypatch) -> None:
|
|
"""All out-of-run writes share the same durable thread-operation admission."""
|
|
from deerflow.runtime import RunManager, RunStatus
|
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
|
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
run_store = MemoryRunStore()
|
|
owner = RunManager(store=run_store, worker_id="worker-a")
|
|
app.state.run_manager = RunManager(store=run_store, worker_id="worker-b")
|
|
accessor = SimpleNamespace(
|
|
graph=None,
|
|
aupdate=AsyncMock(side_effect=AssertionError("state write must not run")),
|
|
aget=AsyncMock(),
|
|
)
|
|
monkeypatch.setattr(
|
|
threads,
|
|
"build_thread_checkpoint_state_mutation_accessor",
|
|
AsyncMock(return_value=(accessor, {"configurable": {"thread_id": "thread-state-race"}})),
|
|
)
|
|
|
|
async def _seed_active_run() -> None:
|
|
active = await owner.create_or_reject("thread-state-race")
|
|
await owner.set_status(active.run_id, RunStatus.running)
|
|
|
|
asyncio.run(_seed_active_run())
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post("/api/threads", json={"thread_id": "thread-state-race"})
|
|
assert created.status_code == 200, created.text
|
|
response = client.post(
|
|
"/api/threads/thread-state-race/state",
|
|
json={"values": {"title": "must not write"}},
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert response.json()["detail"] == "Thread has a run in flight. Update state after the run finishes."
|
|
accessor.aupdate.assert_not_awaited()
|
|
|
|
|
|
class _RawStateAccessor:
|
|
def __init__(self, checkpointer: InMemorySaver):
|
|
self.checkpointer = checkpointer
|
|
|
|
@staticmethod
|
|
def _snapshot(checkpoint_tuple, fallback_config):
|
|
if checkpoint_tuple is None:
|
|
return SimpleNamespace(
|
|
values={},
|
|
config=fallback_config,
|
|
parent_config=None,
|
|
metadata={},
|
|
next=(),
|
|
tasks=(),
|
|
created_at=None,
|
|
)
|
|
checkpoint = checkpoint_tuple.checkpoint or {}
|
|
metadata = checkpoint_tuple.metadata or {}
|
|
return SimpleNamespace(
|
|
values=dict(checkpoint.get("channel_values", {})),
|
|
config=checkpoint_tuple.config,
|
|
parent_config=checkpoint_tuple.parent_config,
|
|
metadata=metadata,
|
|
next=(),
|
|
tasks=(),
|
|
created_at=checkpoint.get("ts") or metadata.get("created_at"),
|
|
)
|
|
|
|
async def aget(self, config):
|
|
checkpoint_tuple = await self.checkpointer.aget_tuple(config)
|
|
return self._snapshot(checkpoint_tuple, config)
|
|
|
|
async def ahistory(self, config, *, limit=None):
|
|
snapshots = []
|
|
async for checkpoint_tuple in self.checkpointer.alist(config, limit=limit):
|
|
snapshots.append(self._snapshot(checkpoint_tuple, config))
|
|
return snapshots
|
|
|
|
async def aupdate(self, config, values, *, as_node=None):
|
|
checkpoint_tuple = await self.checkpointer.aget_tuple(config)
|
|
checkpoint = dict(checkpoint_tuple.checkpoint if checkpoint_tuple is not None else empty_checkpoint())
|
|
channel_values = dict(checkpoint.get("channel_values", {}))
|
|
channel_values.update({key: value.value if isinstance(value, Overwrite) else value for key, value in values.items()})
|
|
checkpoint["channel_values"] = channel_values
|
|
channel_versions = dict(checkpoint.get("channel_versions", {}))
|
|
new_versions = {}
|
|
for key in values:
|
|
current_version = channel_versions.get(key)
|
|
next_version = current_version + 1 if isinstance(current_version, int) else 1
|
|
channel_versions[key] = next_version
|
|
new_versions[key] = next_version
|
|
checkpoint["channel_versions"] = channel_versions
|
|
checkpoint["id"] = str(uuid6())
|
|
metadata = dict(checkpoint_tuple.metadata if checkpoint_tuple is not None else {})
|
|
metadata.update(
|
|
{
|
|
"source": "update",
|
|
"step": metadata.get("step", -1) + 1,
|
|
"writes": {as_node: values},
|
|
}
|
|
)
|
|
write_config = {
|
|
"configurable": {
|
|
"thread_id": config["configurable"]["thread_id"],
|
|
"checkpoint_ns": config["configurable"].get("checkpoint_ns", ""),
|
|
}
|
|
}
|
|
return await self.checkpointer.aput(write_config, checkpoint, metadata, new_versions)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _patch_checkpoint_state_builder(monkeypatch):
|
|
def _builder(request, *, thread_id, assistant_id=None, checkpoint_id=None):
|
|
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
|
if checkpoint_id is not None:
|
|
config["configurable"]["checkpoint_id"] = checkpoint_id
|
|
return _RawStateAccessor(request.app.state.checkpointer), config
|
|
|
|
def _mutation_builder(request, *, thread_id, as_node, checkpoint_id=None, state_schema=None):
|
|
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
|
if checkpoint_id is not None:
|
|
config["configurable"]["checkpoint_id"] = checkpoint_id
|
|
return _RawStateAccessor(request.app.state.checkpointer), config
|
|
|
|
async def _read_boundary(request, *, thread_id, checkpoint_id=None):
|
|
return _builder(request, thread_id=thread_id, checkpoint_id=checkpoint_id)
|
|
|
|
async def _mutation_boundary(request, *, thread_id, as_node, checkpoint_id=None):
|
|
return _mutation_builder(request, thread_id=thread_id, as_node=as_node, checkpoint_id=checkpoint_id)
|
|
|
|
monkeypatch.setattr(threads, "build_checkpoint_state_accessor", _builder)
|
|
monkeypatch.setattr(threads, "build_checkpoint_state_mutation_accessor", _mutation_builder)
|
|
monkeypatch.setattr(threads, "build_thread_checkpoint_state_accessor", _read_boundary)
|
|
monkeypatch.setattr(threads, "build_thread_checkpoint_state_mutation_accessor", _mutation_boundary)
|
|
monkeypatch.setattr(thread_runs, "build_thread_checkpoint_state_accessor", _read_boundary)
|
|
|
|
|
|
class _FakeStateAccessor:
|
|
def __init__(self, snapshot: SimpleNamespace):
|
|
self.snapshot = snapshot
|
|
|
|
async def aget(self, config):
|
|
return self.snapshot
|
|
|
|
async def ahistory(self, config, *, limit=None):
|
|
return [self.snapshot][:limit]
|
|
|
|
|
|
def _materialized_snapshot() -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
values={
|
|
"messages": [
|
|
HumanMessage(id="h1", content="question"),
|
|
AIMessage(id="a1", content="answer"),
|
|
]
|
|
},
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "thread-1",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": "ckpt-2",
|
|
}
|
|
},
|
|
parent_config={"configurable": {"checkpoint_id": "ckpt-1"}},
|
|
metadata={"step": 2},
|
|
next=(),
|
|
tasks=(),
|
|
created_at=None,
|
|
)
|
|
|
|
|
|
async def _write_checkpoint(
|
|
checkpointer: InMemorySaver,
|
|
thread_id: str,
|
|
checkpoint_id: str,
|
|
messages: list[object],
|
|
*,
|
|
step: int,
|
|
metadata: dict | None = None,
|
|
parent_config: dict | None = None,
|
|
) -> dict:
|
|
checkpoint = empty_checkpoint()
|
|
checkpoint["id"] = checkpoint_id
|
|
checkpoint["channel_values"] = {"messages": messages}
|
|
checkpoint["channel_versions"] = {"messages": step}
|
|
checkpoint_metadata = {
|
|
"step": step,
|
|
"source": "loop",
|
|
"writes": {"test": {"messages": messages}},
|
|
"parents": {},
|
|
"created_at": f"2026-07-05T00:00:0{step}+00:00",
|
|
}
|
|
checkpoint_metadata.update(metadata or {})
|
|
return await checkpointer.aput(
|
|
parent_config or {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
|
|
checkpoint,
|
|
checkpoint_metadata,
|
|
{"messages": step},
|
|
)
|
|
|
|
|
|
def test_delete_thread_data_removes_thread_directory(tmp_path):
|
|
paths = Paths(tmp_path)
|
|
thread_dir = paths.thread_dir("thread-cleanup")
|
|
workspace = paths.sandbox_work_dir("thread-cleanup")
|
|
uploads = paths.sandbox_uploads_dir("thread-cleanup")
|
|
outputs = paths.sandbox_outputs_dir("thread-cleanup")
|
|
|
|
for directory in [workspace, uploads, outputs]:
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
(workspace / "notes.txt").write_text("hello", encoding="utf-8")
|
|
(uploads / "report.pdf").write_bytes(b"pdf")
|
|
(outputs / "result.json").write_text("{}", encoding="utf-8")
|
|
|
|
assert thread_dir.exists()
|
|
|
|
response = threads._delete_thread_data("thread-cleanup", paths=paths)
|
|
|
|
assert response.success is True
|
|
assert not thread_dir.exists()
|
|
|
|
|
|
def test_delete_thread_data_is_idempotent_for_missing_directory(tmp_path):
|
|
paths = Paths(tmp_path)
|
|
|
|
response = threads._delete_thread_data("missing-thread", paths=paths)
|
|
|
|
assert response.success is True
|
|
assert not paths.thread_dir("missing-thread").exists()
|
|
|
|
|
|
def test_delete_thread_data_rejects_invalid_thread_id(tmp_path):
|
|
paths = Paths(tmp_path)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
threads._delete_thread_data("../escape", paths=paths)
|
|
|
|
assert exc_info.value.status_code == 422
|
|
assert "Invalid thread_id" in exc_info.value.detail
|
|
|
|
|
|
def test_delete_thread_route_cleans_thread_directory(tmp_path):
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
|
|
paths = Paths(tmp_path)
|
|
user_id = get_effective_user_id()
|
|
thread_dir = paths.thread_dir("thread-route", user_id=user_id)
|
|
paths.sandbox_work_dir("thread-route", user_id=user_id).mkdir(parents=True, exist_ok=True)
|
|
(paths.sandbox_work_dir("thread-route", user_id=user_id) / "notes.txt").write_text("hello", encoding="utf-8")
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(threads.router)
|
|
|
|
with patch("app.gateway.routers.threads.get_paths", return_value=paths):
|
|
with TestClient(app) as client:
|
|
response = client.delete("/api/threads/thread-route")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"success": True, "message": "Deleted local thread data for thread-route"}
|
|
assert not thread_dir.exists()
|
|
|
|
|
|
def test_delete_thread_route_closes_browser_session(tmp_path):
|
|
"""Deleting a thread tears down its live browser session so a later caller
|
|
who reuses the id cannot inherit the retained page/cookies."""
|
|
paths = Paths(tmp_path)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(threads.router)
|
|
|
|
manager = SimpleNamespace(close_session=AsyncMock(return_value=True))
|
|
with (
|
|
patch("app.gateway.routers.threads.get_paths", return_value=paths),
|
|
patch(
|
|
"deerflow.community.browser_automation.get_browser_session_manager",
|
|
return_value=manager,
|
|
),
|
|
):
|
|
with TestClient(app) as client:
|
|
response = client.delete("/api/threads/thread-browser")
|
|
|
|
assert response.status_code == 200
|
|
manager.close_session.assert_awaited_once_with("thread-browser")
|
|
|
|
|
|
def test_delete_thread_route_rejects_invalid_thread_id(tmp_path):
|
|
paths = Paths(tmp_path)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(threads.router)
|
|
|
|
with patch("app.gateway.routers.threads.get_paths", return_value=paths):
|
|
with TestClient(app) as client:
|
|
response = client.delete("/api/threads/../escape")
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_delete_thread_route_returns_422_for_route_safe_invalid_id(tmp_path):
|
|
paths = Paths(tmp_path)
|
|
|
|
app = make_authed_test_app()
|
|
app.include_router(threads.router)
|
|
|
|
with patch("app.gateway.routers.threads.get_paths", return_value=paths):
|
|
with TestClient(app) as client:
|
|
response = client.delete("/api/threads/thread.with.dot")
|
|
|
|
assert response.status_code == 422
|
|
assert "Invalid thread_id" in response.json()["detail"]
|
|
|
|
|
|
def test_delete_thread_data_returns_generic_500_error(tmp_path):
|
|
paths = Paths(tmp_path)
|
|
|
|
with (
|
|
patch.object(paths, "delete_thread_dir", side_effect=OSError("/secret/path")),
|
|
patch.object(threads.logger, "exception") as log_exception,
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
threads._delete_thread_data("thread-cleanup", paths=paths)
|
|
|
|
assert exc_info.value.status_code == 500
|
|
assert exc_info.value.detail == "Failed to delete local thread data."
|
|
assert "/secret/path" not in exc_info.value.detail
|
|
log_exception.assert_called_once_with("Failed to delete thread data for %s", "thread-cleanup")
|
|
|
|
|
|
# ── Server-reserved metadata key stripping ──────────────────────────────────
|
|
|
|
|
|
def test_strip_reserved_metadata_removes_user_id():
|
|
"""Client-supplied user_id is dropped to prevent reflection attacks."""
|
|
out = threads._strip_reserved_metadata({"user_id": "victim-id", "title": "ok"})
|
|
assert out == {"title": "ok"}
|
|
|
|
|
|
def test_strip_reserved_metadata_passes_through_safe_keys():
|
|
"""Non-reserved keys are preserved verbatim."""
|
|
md = {"title": "ok", "tags": ["a", "b"], "custom": {"x": 1}}
|
|
assert threads._strip_reserved_metadata(md) == md
|
|
|
|
|
|
def test_strip_reserved_metadata_empty_input():
|
|
"""Empty / None metadata returns same object — no crash."""
|
|
assert threads._strip_reserved_metadata({}) == {}
|
|
|
|
|
|
def test_strip_reserved_metadata_strips_all_reserved_keys():
|
|
out = threads._strip_reserved_metadata({"user_id": "x", "keep": "me"})
|
|
assert out == {"keep": "me"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ISO 8601 timestamp contract (issue #2594)
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Threads endpoints document ``created_at`` / ``updated_at`` as ISO
|
|
# timestamps and that is the format LangGraph Platform uses
|
|
# (``langgraph_sdk.schema.Thread.created_at: datetime`` JSON-encodes to
|
|
# ISO 8601). The tests below pin that contract end-to-end and also
|
|
# exercise the ``coerce_iso`` healing path for legacy unix-timestamp
|
|
# records written by older Gateway versions.
|
|
|
|
|
|
def test_create_thread_returns_iso_timestamps() -> None:
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post("/api/threads", json={"metadata": {}})
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert _ISO_TIMESTAMP_RE.match(body["created_at"]), body["created_at"]
|
|
assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"]
|
|
assert body["created_at"] == body["updated_at"]
|
|
|
|
|
|
def test_create_thread_returns_existing_when_insert_loses_race() -> None:
|
|
"""A concurrent create that loses the INSERT race stays idempotent.
|
|
|
|
The idempotency ``get`` check and the ``create`` INSERT are not atomic:
|
|
a competing request for the same ``thread_id`` can commit in between, and
|
|
the SQL-backed store then rejects ours on the duplicate primary key. The
|
|
endpoint documents idempotency ("returns the existing record when
|
|
``thread_id`` already exists"), so it must surface the now-present row
|
|
rather than turning the integrity error into an HTTP 500.
|
|
"""
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
app, store, _checkpointer = _build_thread_app()
|
|
|
|
class _RacingThreadMetaStore(_PermissiveThreadMetaStore):
|
|
"""First create loses the race: the row is committed by a competing
|
|
request, then our INSERT fails with an integrity violation."""
|
|
|
|
def __init__(self, backing):
|
|
super().__init__(backing)
|
|
self._raised = False
|
|
|
|
async def create(self, thread_id, *, assistant_id=None, user_id=None, display_name=None, metadata=None): # type: ignore[override]
|
|
if not self._raised:
|
|
self._raised = True
|
|
await super().create(
|
|
thread_id,
|
|
assistant_id=assistant_id,
|
|
user_id=user_id,
|
|
display_name=display_name,
|
|
metadata=metadata,
|
|
)
|
|
raise IntegrityError(
|
|
"INSERT INTO threads_meta",
|
|
{},
|
|
Exception("UNIQUE constraint failed: threads_meta.thread_id"),
|
|
)
|
|
return await super().create(
|
|
thread_id,
|
|
assistant_id=assistant_id,
|
|
user_id=user_id,
|
|
display_name=display_name,
|
|
metadata=metadata,
|
|
)
|
|
|
|
app.state.thread_store = _RacingThreadMetaStore(store)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": "race-thread", "metadata": {"k": "v"}},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert body["thread_id"] == "race-thread"
|
|
assert body["metadata"] == {"k": "v"}
|
|
|
|
|
|
def test_insert_race_recovery_claims_unscoped_row_for_trusted_owner() -> None:
|
|
"""The insert-race recovery mirrors the fast path's owner reconciliation.
|
|
|
|
When a competing request commits a legacy unscoped (``user_id=None``) row
|
|
between our idempotency read and our insert, and our insert then loses the
|
|
duplicate-key race, a trusted internal owner must still claim the row rather
|
|
than return it unowned — otherwise ownership of the same thread would depend
|
|
on whether the fast path or the recovery path resolved it.
|
|
"""
|
|
import asyncio
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
|
|
|
|
store = InMemoryStore()
|
|
checkpointer = InMemorySaver()
|
|
|
|
class _RacingOwnerStore(MemoryThreadMetaStore):
|
|
"""Our insert loses to a competing create that already wrote an
|
|
unscoped row, exactly the interleaving the recovery path exists for."""
|
|
|
|
async def create(self, thread_id, *, assistant_id=None, user_id=None, display_name=None, metadata=None): # type: ignore[override]
|
|
# The competing request commits its (owner-less) row here, then our
|
|
# insert loses the primary-key race.
|
|
await super().create(thread_id, user_id=None, metadata=metadata)
|
|
raise IntegrityError(
|
|
"INSERT INTO threads_meta",
|
|
{},
|
|
Exception("UNIQUE constraint failed: threads_meta.thread_id"),
|
|
)
|
|
|
|
thread_store = _RacingOwnerStore(store)
|
|
request = SimpleNamespace(
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
|
|
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE)),
|
|
app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer, thread_store=thread_store)),
|
|
)
|
|
|
|
async def _scenario():
|
|
response = await threads.create_thread(
|
|
threads.ThreadCreateRequest(thread_id="channel-thread", metadata={"k": "v"}),
|
|
request,
|
|
)
|
|
owner_row = await thread_store.get("channel-thread", user_id="owner-1")
|
|
unscoped_lookup = await thread_store.get("channel-thread", user_id=None)
|
|
return response, owner_row, unscoped_lookup
|
|
|
|
response, owner_row, unscoped_lookup = asyncio.run(_scenario())
|
|
|
|
assert response.thread_id == "channel-thread"
|
|
# Recovery claimed the legacy row for the trusted owner, same as the fast path.
|
|
assert owner_row is not None
|
|
assert owner_row["user_id"] == "owner-1"
|
|
assert unscoped_lookup["user_id"] == "owner-1"
|
|
|
|
|
|
def test_create_thread_does_not_swallow_non_integrity_errors() -> None:
|
|
"""A non-race insert failure must surface as 500, even when a row now exists.
|
|
|
|
The recovery path only rescues the duplicate-key ``IntegrityError`` race; an
|
|
arbitrary failure that happens to coincide with an existing row must not be
|
|
silently returned as a 200 (previously the broad ``except`` did exactly that).
|
|
"""
|
|
app, store, _checkpointer = _build_thread_app()
|
|
|
|
class _BrokenAfterWriteStore(_PermissiveThreadMetaStore):
|
|
async def create(self, thread_id, *, assistant_id=None, user_id=None, display_name=None, metadata=None): # type: ignore[override]
|
|
# A row exists after this call, but the insert failed for a reason
|
|
# unrelated to the idempotency race.
|
|
await super().create(thread_id, metadata=metadata)
|
|
raise RuntimeError("unexpected store failure")
|
|
|
|
app.state.thread_store = _BrokenAfterWriteStore(store)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post("/api/threads", json={"thread_id": "broken-thread", "metadata": {}})
|
|
|
|
assert response.status_code == 500, response.text
|
|
|
|
|
|
def test_put_goal_creates_missing_thread_checkpoint_and_returns_goal() -> None:
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
|
|
with TestClient(app) as client:
|
|
response = client.put(
|
|
"/api/threads/goal-thread/goal",
|
|
json={"objective": "Finish the feature and make all tests pass"},
|
|
)
|
|
state_response = client.get("/api/threads/goal-thread/state")
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert body["goal"]["objective"] == "Finish the feature and make all tests pass"
|
|
assert body["goal"]["status"] == "active"
|
|
assert body["goal"]["continuation_count"] == 0
|
|
assert body["goal"]["max_continuations"] == 8
|
|
assert state_response.status_code == 200, state_response.text
|
|
assert state_response.json()["values"]["goal"]["objective"] == "Finish the feature and make all tests pass"
|
|
|
|
|
|
def test_goal_status_and_clear_round_trip() -> None:
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
|
|
with TestClient(app) as client:
|
|
set_response = client.put(
|
|
"/api/threads/goal-thread/goal",
|
|
json={"objective": "Ship it", "max_continuations": 3},
|
|
)
|
|
get_response = client.get("/api/threads/goal-thread/goal")
|
|
clear_response = client.delete("/api/threads/goal-thread/goal")
|
|
after_clear_response = client.get("/api/threads/goal-thread/goal")
|
|
state_response = client.get("/api/threads/goal-thread/state")
|
|
|
|
assert set_response.status_code == 200, set_response.text
|
|
assert get_response.status_code == 200, get_response.text
|
|
assert get_response.json()["goal"]["objective"] == "Ship it"
|
|
assert get_response.json()["goal"]["max_continuations"] == 3
|
|
assert clear_response.status_code == 200, clear_response.text
|
|
assert clear_response.json()["goal"] is None
|
|
assert after_clear_response.status_code == 200, after_clear_response.text
|
|
assert after_clear_response.json()["goal"] is None
|
|
assert "goal" not in state_response.json()["values"]
|
|
|
|
|
|
def test_goal_mutations_reject_run_owned_by_another_worker() -> None:
|
|
"""PUT and DELETE goal writes share the durable thread-operation boundary."""
|
|
from deerflow.runtime import RunManager, RunStatus
|
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
|
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
run_store = MemoryRunStore()
|
|
owner = RunManager(store=run_store, worker_id="worker-a")
|
|
app.state.run_manager = RunManager(store=run_store, worker_id="worker-b")
|
|
thread_id = "thread-goal-race"
|
|
|
|
async def _seed_active_run() -> None:
|
|
active = await owner.create_or_reject(thread_id)
|
|
await owner.set_status(active.run_id, RunStatus.running)
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post("/api/threads", json={"thread_id": thread_id})
|
|
assert created.status_code == 200, created.text
|
|
initial_goal = client.put(
|
|
f"/api/threads/{thread_id}/goal",
|
|
json={"objective": "Original goal"},
|
|
)
|
|
assert initial_goal.status_code == 200, initial_goal.text
|
|
assert client.portal is not None
|
|
client.portal.call(_seed_active_run)
|
|
put_response = client.put(
|
|
f"/api/threads/{thread_id}/goal",
|
|
json={"objective": "Must not be written"},
|
|
)
|
|
delete_response = client.delete(f"/api/threads/{thread_id}/goal")
|
|
goal_response = client.get(f"/api/threads/{thread_id}/goal")
|
|
|
|
assert put_response.status_code == 409, put_response.text
|
|
assert delete_response.status_code == 409, delete_response.text
|
|
assert goal_response.status_code == 200, goal_response.text
|
|
assert goal_response.json()["goal"]["objective"] == "Original goal"
|
|
|
|
|
|
def test_internal_owner_header_assigns_thread_to_owner() -> None:
|
|
import asyncio
|
|
|
|
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
|
|
|
|
store = InMemoryStore()
|
|
checkpointer = InMemorySaver()
|
|
thread_store = MemoryThreadMetaStore(store)
|
|
request = SimpleNamespace(
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
|
|
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE)),
|
|
app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer, thread_store=thread_store)),
|
|
)
|
|
|
|
async def _scenario():
|
|
response = await threads.create_thread(
|
|
threads.ThreadCreateRequest(thread_id="channel-thread", metadata={}),
|
|
request,
|
|
)
|
|
owner_row = await thread_store.get("channel-thread", user_id="owner-1")
|
|
internal_row = await thread_store.get("channel-thread", user_id="default")
|
|
return response, owner_row, internal_row
|
|
|
|
response, owner_row, internal_row = asyncio.run(_scenario())
|
|
|
|
assert response.thread_id == "channel-thread"
|
|
assert owner_row is not None
|
|
assert owner_row["user_id"] == "owner-1"
|
|
assert internal_row is None
|
|
|
|
|
|
def test_goal_thread_creation_uses_internal_owner_header() -> None:
|
|
import asyncio
|
|
|
|
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
|
|
|
|
store = InMemoryStore()
|
|
checkpointer = InMemorySaver()
|
|
thread_store = MemoryThreadMetaStore(store)
|
|
request = SimpleNamespace(
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
|
|
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE)),
|
|
app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer, thread_store=thread_store)),
|
|
)
|
|
|
|
async def _scenario():
|
|
await threads._ensure_thread_for_goal("channel-goal-thread", request)
|
|
owner_row = await thread_store.get("channel-goal-thread", user_id="owner-1")
|
|
internal_row = await thread_store.get("channel-goal-thread", user_id="default")
|
|
owner_threads = await thread_store.search(user_id="owner-1")
|
|
return owner_row, internal_row, owner_threads
|
|
|
|
owner_row, internal_row, owner_threads = asyncio.run(_scenario())
|
|
|
|
assert owner_row is not None
|
|
assert owner_row["user_id"] == "owner-1"
|
|
assert internal_row is None
|
|
assert [thread["thread_id"] for thread in owner_threads] == ["channel-goal-thread"]
|
|
|
|
|
|
def test_get_thread_returns_iso_for_legacy_unix_record() -> None:
|
|
"""A thread record written by older versions stores ``time.time()``
|
|
floats. ``get_thread`` must transparently surface them as ISO so the
|
|
frontend's ``new Date(...)`` parser does not break.
|
|
"""
|
|
app, store, checkpointer = _build_thread_app()
|
|
|
|
legacy_thread_id = "legacy-thread"
|
|
legacy_ts = "1777252410.411327"
|
|
|
|
async def _seed() -> None:
|
|
await store.aput(
|
|
THREADS_NS,
|
|
legacy_thread_id,
|
|
{
|
|
"thread_id": legacy_thread_id,
|
|
"status": "idle",
|
|
"created_at": legacy_ts,
|
|
"updated_at": legacy_ts,
|
|
"metadata": {},
|
|
},
|
|
)
|
|
from langgraph.checkpoint.base import empty_checkpoint
|
|
|
|
await checkpointer.aput(
|
|
{"configurable": {"thread_id": legacy_thread_id, "checkpoint_ns": ""}},
|
|
empty_checkpoint(),
|
|
{"step": -1, "source": "input", "writes": None, "parents": {}},
|
|
{},
|
|
)
|
|
|
|
import asyncio
|
|
|
|
asyncio.run(_seed())
|
|
|
|
with TestClient(app) as client:
|
|
response = client.get(f"/api/threads/{legacy_thread_id}")
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert _ISO_TIMESTAMP_RE.match(body["created_at"]), body["created_at"]
|
|
assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"]
|
|
|
|
|
|
def test_latest_thread_readers_use_materialized_snapshot_values() -> None:
|
|
app, store, checkpointer = _build_thread_app()
|
|
thread_id = "thread-1"
|
|
|
|
async def _seed() -> None:
|
|
await store.aput(
|
|
THREADS_NS,
|
|
thread_id,
|
|
{
|
|
"thread_id": thread_id,
|
|
"status": "idle",
|
|
"created_at": "2026-07-18T00:00:00+00:00",
|
|
"updated_at": "2026-07-18T00:00:00+00:00",
|
|
"metadata": {},
|
|
},
|
|
)
|
|
await checkpointer.aput(
|
|
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
|
|
empty_checkpoint(),
|
|
{"step": 2, "source": "loop", "writes": {}, "parents": {}},
|
|
{},
|
|
)
|
|
|
|
asyncio.run(_seed())
|
|
accessor = _FakeStateAccessor(_materialized_snapshot())
|
|
thread_accessor = AsyncMock(return_value=(accessor, {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}))
|
|
|
|
with (
|
|
patch(
|
|
"app.gateway.routers.threads.build_checkpoint_state_accessor",
|
|
create=True,
|
|
return_value=(accessor, {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}),
|
|
),
|
|
patch(
|
|
"app.gateway.routers.threads.build_thread_checkpoint_state_accessor",
|
|
new=thread_accessor,
|
|
),
|
|
TestClient(app) as client,
|
|
):
|
|
thread_response = client.get(f"/api/threads/{thread_id}")
|
|
state_response = client.get(f"/api/threads/{thread_id}/state")
|
|
history_response = client.post(f"/api/threads/{thread_id}/history", json={"limit": 10})
|
|
|
|
assert thread_response.status_code == 200, thread_response.text
|
|
assert state_response.status_code == 200, state_response.text
|
|
assert history_response.status_code == 200, history_response.text
|
|
assert [message["id"] for message in thread_response.json()["values"]["messages"]] == ["h1", "a1"]
|
|
assert [message["id"] for message in state_response.json()["values"]["messages"]] == ["h1", "a1"]
|
|
assert [message["id"] for message in history_response.json()[0]["values"]["messages"]] == ["h1", "a1"]
|
|
assert [call.kwargs["thread_id"] for call in thread_accessor.await_args_list] == [thread_id, thread_id]
|
|
|
|
|
|
def test_get_thread_status_uses_raw_pending_writes_for_materialized_checkpoint() -> None:
|
|
app, store, _checkpointer = _build_thread_app()
|
|
thread_id = "thread-1"
|
|
|
|
async def _seed() -> None:
|
|
await store.aput(
|
|
THREADS_NS,
|
|
thread_id,
|
|
{
|
|
"thread_id": thread_id,
|
|
"status": "idle",
|
|
"created_at": "2026-07-18T00:00:00+00:00",
|
|
"updated_at": "2026-07-18T00:00:00+00:00",
|
|
"metadata": {},
|
|
},
|
|
)
|
|
|
|
asyncio.run(_seed())
|
|
requested_configs = []
|
|
|
|
class _AdvancingCheckpointer:
|
|
async def aget_tuple(self, config):
|
|
requested_configs.append(config)
|
|
checkpoint_id = config.get("configurable", {}).get("checkpoint_id")
|
|
return SimpleNamespace(
|
|
pending_writes=[] if checkpoint_id == "ckpt-2" else [("task-old", "__error__", "stale")],
|
|
)
|
|
|
|
app.state.checkpointer = _AdvancingCheckpointer()
|
|
snapshot = _materialized_snapshot()
|
|
accessor = _FakeStateAccessor(snapshot)
|
|
|
|
with (
|
|
patch(
|
|
"app.gateway.routers.threads.build_checkpoint_state_accessor",
|
|
return_value=(accessor, {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}),
|
|
),
|
|
TestClient(app) as client,
|
|
):
|
|
response = client.get(f"/api/threads/{thread_id}")
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["status"] == "idle"
|
|
assert requested_configs == [snapshot.config]
|
|
|
|
|
|
@pytest.mark.parametrize("stored_status", ["running", "error"])
|
|
def test_get_thread_preserves_metadata_status_without_checkpoint(stored_status: str) -> None:
|
|
app, store, _checkpointer = _build_thread_app()
|
|
thread_id = "thread-without-checkpoint"
|
|
|
|
async def _seed() -> None:
|
|
await store.aput(
|
|
THREADS_NS,
|
|
thread_id,
|
|
{
|
|
"thread_id": thread_id,
|
|
"status": stored_status,
|
|
"created_at": "2026-07-18T00:00:00+00:00",
|
|
"updated_at": "2026-07-18T00:00:00+00:00",
|
|
"metadata": {},
|
|
},
|
|
)
|
|
|
|
asyncio.run(_seed())
|
|
snapshot = SimpleNamespace(
|
|
values={},
|
|
config={"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
|
|
parent_config=None,
|
|
metadata={},
|
|
next=(),
|
|
tasks=(),
|
|
created_at=None,
|
|
)
|
|
accessor = _FakeStateAccessor(snapshot)
|
|
|
|
with (
|
|
patch(
|
|
"app.gateway.routers.threads.build_checkpoint_state_accessor",
|
|
return_value=(accessor, snapshot.config),
|
|
),
|
|
TestClient(app) as client,
|
|
):
|
|
response = client.get(f"/api/threads/{thread_id}")
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["status"] == stored_status
|
|
|
|
|
|
def test_patch_thread_pin_returns_iso_and_preserves_updated_at() -> None:
|
|
"""A pin/unpin PATCH must not bump ``updated_at``.
|
|
|
|
Pinning or unpinning a chat does not represent conversation activity.
|
|
Timestamps are still surfaced as ISO via ``coerce_iso``.
|
|
"""
|
|
app, store, _checkpointer = _build_thread_app()
|
|
thread_id = "patch-target"
|
|
|
|
legacy_created = "1777000000.000000"
|
|
legacy_updated = "1777000000.000000"
|
|
|
|
async def _seed() -> None:
|
|
await store.aput(
|
|
THREADS_NS,
|
|
thread_id,
|
|
{
|
|
"thread_id": thread_id,
|
|
"status": "idle",
|
|
"created_at": legacy_created,
|
|
"updated_at": legacy_updated,
|
|
"metadata": {"k": "v0"},
|
|
},
|
|
)
|
|
|
|
import asyncio
|
|
|
|
asyncio.run(_seed())
|
|
|
|
with TestClient(app) as client:
|
|
response = client.patch(
|
|
f"/api/threads/{thread_id}",
|
|
json={"metadata": {THREAD_PINNED_METADATA_KEY: True}},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert _ISO_TIMESTAMP_RE.match(body["created_at"]), body["created_at"]
|
|
assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"]
|
|
# ``touch=False`` preserves the original ``updated_at``; both timestamps
|
|
# derive from the same legacy value, so they coerce to the same ISO string.
|
|
assert body["updated_at"] == body["created_at"]
|
|
assert body["metadata"] == {"k": "v0", THREAD_PINNED_METADATA_KEY: True}
|
|
|
|
|
|
def test_patch_thread_non_pin_metadata_bumps_updated_at() -> None:
|
|
"""The public metadata PATCH endpoint still bumps recency by default."""
|
|
app, store, _checkpointer = _build_thread_app()
|
|
thread_id = "patch-target"
|
|
|
|
legacy_created = "946684800.000000"
|
|
legacy_updated = "946684800.000000"
|
|
|
|
async def _seed() -> None:
|
|
await store.aput(
|
|
THREADS_NS,
|
|
thread_id,
|
|
{
|
|
"thread_id": thread_id,
|
|
"status": "idle",
|
|
"created_at": legacy_created,
|
|
"updated_at": legacy_updated,
|
|
"metadata": {"k": "v0"},
|
|
},
|
|
)
|
|
|
|
import asyncio
|
|
|
|
asyncio.run(_seed())
|
|
|
|
with TestClient(app) as client:
|
|
response = client.patch(f"/api/threads/{thread_id}", json={"metadata": {"k": "v1"}})
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"]
|
|
assert body["updated_at"] != body["created_at"]
|
|
assert body["metadata"] == {"k": "v1"}
|
|
|
|
|
|
def test_search_threads_normalizes_legacy_unix_seconds_to_iso() -> None:
|
|
"""``MemoryThreadMetaStore`` may hold legacy ``time.time()`` floats
|
|
written by older Gateway versions. ``/search`` must surface them as
|
|
ISO via ``coerce_iso`` so the frontend's ``new Date(...)`` parser
|
|
does not break.
|
|
"""
|
|
app, store, _checkpointer = _build_thread_app()
|
|
|
|
async def _seed() -> None:
|
|
# Legacy unix-second float (the literal value from issue #2594).
|
|
await store.aput(
|
|
THREADS_NS,
|
|
"legacy",
|
|
{
|
|
"thread_id": "legacy",
|
|
"status": "idle",
|
|
"created_at": 1777000000.0,
|
|
"updated_at": 1777000000.0,
|
|
"metadata": {},
|
|
},
|
|
)
|
|
# Modern ISO string, slightly later.
|
|
await store.aput(
|
|
THREADS_NS,
|
|
"modern",
|
|
{
|
|
"thread_id": "modern",
|
|
"status": "idle",
|
|
"created_at": "2026-04-27T00:00:00+00:00",
|
|
"updated_at": "2026-04-27T00:00:00+00:00",
|
|
"metadata": {},
|
|
},
|
|
)
|
|
|
|
import asyncio
|
|
|
|
asyncio.run(_seed())
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post("/api/threads/search", json={"limit": 10})
|
|
|
|
assert response.status_code == 200, response.text
|
|
items = response.json()
|
|
assert {item["thread_id"] for item in items} == {"legacy", "modern"}
|
|
for item in items:
|
|
assert _ISO_TIMESTAMP_RE.match(item["created_at"]), item
|
|
assert _ISO_TIMESTAMP_RE.match(item["updated_at"]), item
|
|
|
|
|
|
def test_search_threads_returns_pinned_threads_before_newer_unpinned_threads() -> None:
|
|
app, store, _checkpointer = _build_thread_app()
|
|
|
|
async def _seed() -> None:
|
|
await store.aput(
|
|
THREADS_NS,
|
|
"newer-unpinned",
|
|
{
|
|
"thread_id": "newer-unpinned",
|
|
"status": "idle",
|
|
"created_at": "2026-07-01T00:00:00+00:00",
|
|
"updated_at": "2026-07-20T00:00:00+00:00",
|
|
"metadata": {},
|
|
},
|
|
)
|
|
await store.aput(
|
|
THREADS_NS,
|
|
"older-pinned",
|
|
{
|
|
"thread_id": "older-pinned",
|
|
"status": "idle",
|
|
"created_at": "2026-06-01T00:00:00+00:00",
|
|
"updated_at": "2026-06-01T00:00:00+00:00",
|
|
"metadata": {THREAD_PINNED_METADATA_KEY: True},
|
|
},
|
|
)
|
|
|
|
import asyncio
|
|
|
|
asyncio.run(_seed())
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post("/api/threads/search", json={"limit": 1})
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert [item["thread_id"] for item in response.json()] == ["older-pinned"]
|
|
|
|
|
|
def test_memory_thread_meta_store_writes_iso_on_create() -> None:
|
|
"""``MemoryThreadMetaStore.create`` must emit ISO so newly created
|
|
threads serialize correctly without depending on the router's
|
|
``coerce_iso`` heal path.
|
|
"""
|
|
import asyncio
|
|
|
|
store = InMemoryStore()
|
|
repo = MemoryThreadMetaStore(store)
|
|
|
|
async def _scenario() -> dict:
|
|
await repo.create("fresh", user_id=None, metadata={"a": 1})
|
|
record = (await store.aget(THREADS_NS, "fresh")).value
|
|
return record
|
|
|
|
record = asyncio.run(_scenario())
|
|
assert _ISO_TIMESTAMP_RE.match(record["created_at"]), record
|
|
assert _ISO_TIMESTAMP_RE.match(record["updated_at"]), record
|
|
|
|
|
|
def test_get_thread_state_returns_iso_for_legacy_checkpoint_metadata() -> None:
|
|
"""Checkpoints written by older Gateway versions stored
|
|
``created_at`` as a unix-second float in their metadata. The
|
|
``/state`` endpoint must surface that value as ISO so the frontend's
|
|
``new Date(...)`` parser does not break — same root cause as the
|
|
thread-record bug fixed in #2594, but on the checkpoint side.
|
|
"""
|
|
app, _store, checkpointer = _build_thread_app()
|
|
thread_id = "legacy-state"
|
|
|
|
async def _seed() -> None:
|
|
from langgraph.checkpoint.base import empty_checkpoint
|
|
|
|
await checkpointer.aput(
|
|
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
|
|
empty_checkpoint(),
|
|
{"step": -1, "source": "input", "writes": None, "parents": {}, "created_at": 1777252410.411327},
|
|
{},
|
|
)
|
|
|
|
import asyncio
|
|
|
|
asyncio.run(_seed())
|
|
|
|
with TestClient(app) as client:
|
|
response = client.get(f"/api/threads/{thread_id}/state")
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert _ISO_TIMESTAMP_RE.match(body["created_at"]), body["created_at"]
|
|
assert _ISO_TIMESTAMP_RE.match(body["checkpoint"]["ts"]), body["checkpoint"]
|
|
|
|
|
|
def test_get_thread_history_returns_iso_for_legacy_checkpoint_metadata() -> None:
|
|
"""``/history`` walks ``checkpointer.alist`` and emits one entry per
|
|
checkpoint. Each entry's ``created_at`` must come out as ISO even if
|
|
older checkpoints stored a unix-second float in their metadata.
|
|
"""
|
|
app, _store, checkpointer = _build_thread_app()
|
|
thread_id = "legacy-history"
|
|
|
|
async def _seed() -> None:
|
|
from langgraph.checkpoint.base import empty_checkpoint
|
|
|
|
await checkpointer.aput(
|
|
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
|
|
empty_checkpoint(),
|
|
{"step": -1, "source": "input", "writes": None, "parents": {}, "created_at": 1777252410.411327},
|
|
{},
|
|
)
|
|
|
|
import asyncio
|
|
|
|
asyncio.run(_seed())
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(f"/api/threads/{thread_id}/history", json={"limit": 10})
|
|
|
|
assert response.status_code == 200, response.text
|
|
entries = response.json()
|
|
assert entries, "expected at least one history entry"
|
|
for entry in entries:
|
|
assert _ISO_TIMESTAMP_RE.match(entry["created_at"]), entry
|
|
|
|
|
|
def test_get_thread_history_associates_tool_messages_from_checkpoint_turn() -> None:
|
|
app, _store, checkpointer = _build_thread_app()
|
|
thread_id = "history-tool-run"
|
|
messages = [
|
|
HumanMessage(id="human-1", content="Use a tool", additional_kwargs={"run_id": "run-1"}),
|
|
AIMessage(
|
|
id="ai-1",
|
|
content="Calling tool",
|
|
tool_calls=[{"name": "lookup", "args": {}, "id": "call-1"}],
|
|
),
|
|
ToolMessage(id="tool-1", content="result", tool_call_id="call-1"),
|
|
AIMessage(id="ai-2", content="Done"),
|
|
]
|
|
|
|
asyncio.run(
|
|
_write_checkpoint(
|
|
checkpointer,
|
|
thread_id,
|
|
"checkpoint-tool-run",
|
|
messages,
|
|
step=1,
|
|
metadata={"run_durations": {"run-1": 4}},
|
|
)
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(f"/api/threads/{thread_id}/history", json={"limit": 10})
|
|
|
|
assert response.status_code == 200, response.text
|
|
history_messages = response.json()[0]["values"]["messages"]
|
|
assert [message.get("run_id") for message in history_messages[1:]] == ["run-1", "run-1", "run-1"]
|
|
|
|
assert [message["additional_kwargs"]["turn_duration"] for message in history_messages if message["type"] == "ai"] == [4, 4]
|
|
|
|
|
|
def test_get_thread_history_backfills_legacy_durations_with_exact_event_run_id() -> None:
|
|
app, _store, checkpointer = _build_thread_app()
|
|
thread_id = "legacy-history-run-id"
|
|
messages = [
|
|
HumanMessage(id="human-1", content="Question", additional_kwargs={"run_id": "boundary-run"}),
|
|
AIMessage(id="ai-1", content="Answer"),
|
|
ToolMessage(id="tool-1", content="result", tool_call_id="call-1"),
|
|
]
|
|
asyncio.run(_write_checkpoint(checkpointer, thread_id, "00000000-0000-6000-8000-000000000001", messages, step=1))
|
|
|
|
async def list_by_thread(_: str) -> list[SimpleNamespace]:
|
|
return [
|
|
SimpleNamespace(
|
|
run_id="boundary-run",
|
|
created_at="2026-07-05T00:00:00+00:00",
|
|
updated_at="2026-07-05T00:00:03+00:00",
|
|
),
|
|
SimpleNamespace(
|
|
run_id="exact-run",
|
|
created_at="2026-07-05T00:00:00+00:00",
|
|
updated_at="2026-07-05T00:00:07+00:00",
|
|
),
|
|
]
|
|
|
|
async def list_messages(_: str, *, limit: int) -> list[dict]:
|
|
assert limit == 1000
|
|
return [{"content": {"type": "ai", "id": "ai-1"}, "run_id": "exact-run"}]
|
|
|
|
app.state.run_manager = SimpleNamespace(list_by_thread=list_by_thread)
|
|
app.state.run_event_store = SimpleNamespace(list_messages=list_messages)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(f"/api/threads/{thread_id}/history", json={"limit": 10})
|
|
|
|
assert response.status_code == 200, response.text
|
|
entry = response.json()[0]
|
|
history_messages = entry["values"]["messages"]
|
|
assert history_messages[1]["run_id"] == "exact-run"
|
|
assert history_messages[1]["additional_kwargs"]["turn_duration"] == 7
|
|
assert history_messages[2]["run_id"] == "boundary-run"
|
|
assert "run_durations" not in entry["metadata"]
|
|
|
|
latest = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}))
|
|
assert latest is not None
|
|
assert latest.metadata["run_durations"] == {"boundary-run": 3, "exact-run": 7}
|
|
|
|
|
|
def test_ai_message_lacks_duration_only_for_unannotated_ai_messages() -> None:
|
|
assert threads._ai_message_lacks_duration({"type": "ai"})
|
|
assert threads._ai_message_lacks_duration({"type": "ai", "additional_kwargs": []})
|
|
assert not threads._ai_message_lacks_duration({"type": "tool"})
|
|
assert not threads._ai_message_lacks_duration({"type": "ai", "additional_kwargs": {"turn_duration": 0}})
|
|
|
|
|
|
# ── branch threads from completed assistant turns ─────────────────────────────
|
|
|
|
|
|
def test_branch_thread_can_prepare_regenerate_without_branch_run_events() -> None:
|
|
app, _store, checkpointer = _build_thread_app()
|
|
app.include_router(thread_runs.router)
|
|
source_thread_id = "source-regenerate"
|
|
source_run_id = "source-run"
|
|
|
|
async def list_messages(_thread_id: str, *, limit: int, **_kwargs) -> list[dict]:
|
|
assert limit == thread_runs.REGENERATE_HISTORY_SCAN_LIMIT
|
|
return []
|
|
|
|
async def list_by_thread(_thread_id: str, *, user_id=None, limit: int = 100) -> list:
|
|
return []
|
|
|
|
app.state.run_event_store = SimpleNamespace(list_messages=list_messages)
|
|
app.state.run_manager.list_by_thread = list_by_thread
|
|
|
|
human = HumanMessage(id="human-1", content="Question", additional_kwargs={"run_id": source_run_id})
|
|
ai = AIMessage(id="ai-1", content="Answer")
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post("/api/threads", json={"thread_id": source_thread_id, "metadata": {}})
|
|
assert created.status_code == 200, created.text
|
|
|
|
initial = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}}))
|
|
assert initial is not None
|
|
after_human = asyncio.run(
|
|
_write_checkpoint(
|
|
checkpointer,
|
|
source_thread_id,
|
|
str(uuid6()),
|
|
[human],
|
|
step=1,
|
|
parent_config=initial.config,
|
|
)
|
|
)
|
|
asyncio.run(
|
|
_write_checkpoint(
|
|
checkpointer,
|
|
source_thread_id,
|
|
str(uuid6()),
|
|
[human, ai],
|
|
step=2,
|
|
parent_config=after_human,
|
|
)
|
|
)
|
|
|
|
branch_response = client.post(
|
|
f"/api/threads/{source_thread_id}/branches",
|
|
json={"message_id": "ai-1", "message_ids": ["ai-1"]},
|
|
)
|
|
assert branch_response.status_code == 200, branch_response.text
|
|
branch_thread_id = branch_response.json()["thread_id"]
|
|
|
|
prepare_response = client.post(
|
|
f"/api/threads/{branch_thread_id}/runs/regenerate/prepare",
|
|
json={"message_id": "ai-1"},
|
|
)
|
|
|
|
assert prepare_response.status_code == 200, prepare_response.text
|
|
prepared = prepare_response.json()
|
|
assert prepared["target_run_id"] == source_run_id
|
|
branch_base_id = prepared["checkpoint"]["checkpoint_id"]
|
|
assert prepared["input"]["messages"][0]["id"] == "human-1"
|
|
assert prepared["input"]["messages"][0]["content"] == [{"type": "text", "text": "Question"}]
|
|
|
|
branch_base = asyncio.run(
|
|
checkpointer.aget_tuple(
|
|
{
|
|
"configurable": {
|
|
"thread_id": branch_thread_id,
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": branch_base_id,
|
|
}
|
|
}
|
|
)
|
|
)
|
|
assert branch_base is not None
|
|
assert branch_base.checkpoint.get("channel_values", {}).get("messages", []) == []
|
|
|
|
|
|
def test_branch_thread_from_older_assistant_turn_creates_truncated_thread() -> None:
|
|
app, store, checkpointer = _build_thread_app()
|
|
source_thread_id = "source-thread"
|
|
|
|
human_1 = HumanMessage(id="human-1", content="First question")
|
|
ai_1 = AIMessage(id="ai-1", content="First answer")
|
|
human_2 = HumanMessage(id="human-2", content="Second question")
|
|
ai_2 = AIMessage(id="ai-2", content="Second answer")
|
|
human_3 = HumanMessage(id="human-3", content="Third question")
|
|
ai_3 = AIMessage(id="ai-3", content="Third answer")
|
|
|
|
async def _seed(parent_config: dict) -> dict:
|
|
after_human_1 = await _write_checkpoint(checkpointer, source_thread_id, str(uuid6()), [human_1], step=1, parent_config=parent_config)
|
|
after_ai_1 = await _write_checkpoint(checkpointer, source_thread_id, str(uuid6()), [human_1, ai_1], step=2, parent_config=after_human_1)
|
|
after_human_2 = await _write_checkpoint(
|
|
checkpointer,
|
|
source_thread_id,
|
|
str(uuid6()),
|
|
[human_1, ai_1, human_2],
|
|
step=3,
|
|
parent_config=after_ai_1,
|
|
)
|
|
after_ai_2 = await _write_checkpoint(
|
|
checkpointer,
|
|
source_thread_id,
|
|
str(uuid6()),
|
|
[human_1, ai_1, human_2, ai_2],
|
|
step=4,
|
|
parent_config=after_human_2,
|
|
)
|
|
after_human_3 = await _write_checkpoint(
|
|
checkpointer,
|
|
source_thread_id,
|
|
str(uuid6()),
|
|
[human_1, ai_1, human_2, ai_2, human_3],
|
|
step=5,
|
|
parent_config=after_ai_2,
|
|
)
|
|
await _write_checkpoint(
|
|
checkpointer,
|
|
source_thread_id,
|
|
str(uuid6()),
|
|
[human_1, ai_1, human_2, ai_2, human_3, ai_3],
|
|
step=6,
|
|
parent_config=after_human_3,
|
|
)
|
|
return after_ai_2
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post("/api/threads", json={"thread_id": source_thread_id, "metadata": {}, "assistant_id": "agent"})
|
|
assert created.status_code == 200, created.text
|
|
initial = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}}))
|
|
assert initial is not None
|
|
target_checkpoint_config = asyncio.run(_seed(initial.config))
|
|
asyncio.run(
|
|
store.aput(
|
|
THREADS_NS,
|
|
source_thread_id,
|
|
{
|
|
"thread_id": source_thread_id,
|
|
"assistant_id": "agent",
|
|
"user_id": None,
|
|
"status": "idle",
|
|
"created_at": "2026-07-05T00:00:00Z",
|
|
"updated_at": "2026-07-05T00:00:00Z",
|
|
"display_name": "Original chat",
|
|
"metadata": {},
|
|
},
|
|
)
|
|
)
|
|
|
|
response = client.post(
|
|
f"/api/threads/{source_thread_id}/branches",
|
|
json={"message_id": "ai-2", "message_ids": ["ai-2"]},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
new_thread_id = body["thread_id"]
|
|
state_response = client.get(f"/api/threads/{new_thread_id}/state")
|
|
search_response = client.post("/api/threads/search", json={"limit": 10})
|
|
|
|
assert body["parent_thread_id"] == source_thread_id
|
|
assert body["parent_checkpoint_id"] == target_checkpoint_config["configurable"]["checkpoint_id"]
|
|
assert body["branched_from_message_id"] == "ai-2"
|
|
assert body["workspace_clone_mode"] == "skipped_historical_turn"
|
|
|
|
assert state_response.status_code == 200, state_response.text
|
|
messages = state_response.json()["values"]["messages"]
|
|
assert [message["id"] for message in messages] == ["human-1", "ai-1", "human-2", "ai-2"]
|
|
assert "Third answer" not in [message.get("content") for message in messages]
|
|
assert search_response.status_code == 200, search_response.text
|
|
branch_entry = next(item for item in search_response.json() if item["thread_id"] == new_thread_id)
|
|
assert branch_entry["values"]["title"] == "Original chat"
|
|
|
|
|
|
def test_branch_thread_uses_materialized_history_and_overwrites_fresh_seed(monkeypatch) -> None:
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
source_thread_id = "source-materialized"
|
|
messages = [
|
|
HumanMessage(id="h1", content="First question"),
|
|
AIMessage(id="a1", content="First answer"),
|
|
HumanMessage(id="h2", content="Second question"),
|
|
AIMessage(id="a2", content="Second answer"),
|
|
]
|
|
|
|
def snapshot(checkpoint_id: str, materialized_messages: list[object], *, parent_id: str | None = None) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
values={"messages": materialized_messages, "title": "Materialized title"},
|
|
config={
|
|
"configurable": {
|
|
"thread_id": source_thread_id,
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": checkpoint_id,
|
|
}
|
|
},
|
|
metadata={"step": int(checkpoint_id[-1])},
|
|
parent_config=(
|
|
{
|
|
"configurable": {
|
|
"thread_id": source_thread_id,
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": parent_id,
|
|
}
|
|
}
|
|
if parent_id is not None
|
|
else None
|
|
),
|
|
)
|
|
|
|
source_accessor = SimpleNamespace()
|
|
source_history = [
|
|
snapshot("ckpt-2", messages, parent_id="ckpt-1"),
|
|
snapshot("ckpt-1", messages[:2], parent_id="ckpt-0"),
|
|
snapshot("ckpt-0", []),
|
|
]
|
|
branch_updates: list[tuple[dict, dict, str | None]] = []
|
|
|
|
async def source_ahistory(config, *, limit=None):
|
|
assert config["configurable"]["thread_id"] == source_thread_id
|
|
assert limit == threads._BRANCH_HISTORY_RAW_SCAN_LIMIT
|
|
return source_history
|
|
|
|
async def source_aget(config):
|
|
checkpoint_id = config["configurable"]["checkpoint_id"]
|
|
return next(item for item in source_history if item.config["configurable"]["checkpoint_id"] == checkpoint_id)
|
|
|
|
async def branch_aupdate(config, values, *, as_node=None):
|
|
branch_updates.append((config, values, as_node))
|
|
return {
|
|
"configurable": {
|
|
"thread_id": config["configurable"]["thread_id"],
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": f"branch-{len(branch_updates)}",
|
|
}
|
|
}
|
|
|
|
source_accessor.ahistory = source_ahistory
|
|
source_accessor.aget = source_aget
|
|
branch_accessor = SimpleNamespace(aupdate=branch_aupdate)
|
|
|
|
def build_accessor(_request, *, thread_id, assistant_id=None, checkpoint_id=None):
|
|
assert thread_id == source_thread_id
|
|
return source_accessor, {
|
|
"configurable": {
|
|
"thread_id": source_thread_id,
|
|
"checkpoint_ns": "",
|
|
}
|
|
}
|
|
|
|
def build_mutation_accessor(_request, *, thread_id, as_node, checkpoint_id=None, state_schema=None):
|
|
return branch_accessor, {
|
|
"configurable": {
|
|
"thread_id": thread_id,
|
|
"checkpoint_ns": "",
|
|
}
|
|
}
|
|
|
|
monkeypatch.setattr(threads, "build_checkpoint_state_accessor", build_accessor)
|
|
monkeypatch.setattr(threads, "build_checkpoint_state_mutation_accessor", build_mutation_accessor)
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": source_thread_id, "metadata": {}, "assistant_id": "agent"},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
response = client.post(
|
|
f"/api/threads/{source_thread_id}/branches",
|
|
json={"message_id": "a1", "message_ids": ["a1"]},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert body["parent_checkpoint_id"] == "ckpt-1"
|
|
assert len(branch_updates) == 2
|
|
replay_config, replay_values, replay_node = branch_updates[0]
|
|
assert isinstance(replay_values["messages"], Overwrite)
|
|
assert replay_values["messages"].value == []
|
|
assert replay_config["configurable"]["thread_id"] == body["thread_id"]
|
|
assert replay_config["metadata"]["source"] == "branch"
|
|
assert replay_node == "branch"
|
|
|
|
head_config, head_values, head_node = branch_updates[1]
|
|
assert isinstance(head_values["messages"], Overwrite)
|
|
assert [message.id for message in head_values["messages"].value] == ["h1", "a1"]
|
|
assert head_config["configurable"]["checkpoint_id"] == "branch-1"
|
|
assert head_config["metadata"]["source"] == "branch"
|
|
assert head_node == "branch"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("include_replay_base", "expected_message_ids"),
|
|
[
|
|
(True, [[], ["h1", "a1"]]),
|
|
(False, [["h1", "a1"]]),
|
|
],
|
|
ids=["chronological-replay-base", "legacy-single-checkpoint"],
|
|
)
|
|
def test_branch_thread_preserves_unlinked_legacy_histories(
|
|
monkeypatch,
|
|
include_replay_base: bool,
|
|
expected_message_ids: list[list[str]],
|
|
) -> None:
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
source_thread_id = "source-unlinked"
|
|
messages = [
|
|
HumanMessage(id="h1", content="Question"),
|
|
AIMessage(id="a1", content="Answer"),
|
|
]
|
|
|
|
def snapshot(checkpoint_id: str, snapshot_messages: list[object], *, duration_only: bool = False) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
values={"messages": snapshot_messages},
|
|
config={
|
|
"configurable": {
|
|
"thread_id": source_thread_id,
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": checkpoint_id,
|
|
}
|
|
},
|
|
metadata={"writes": {"runtime_run_duration": 1}} if duration_only else {},
|
|
parent_config=None,
|
|
)
|
|
|
|
source_history = [snapshot("ckpt-1", messages)]
|
|
if include_replay_base:
|
|
source_history.extend(
|
|
[
|
|
snapshot("ckpt-duration", [], duration_only=True),
|
|
snapshot("ckpt-0", []),
|
|
]
|
|
)
|
|
|
|
history_limits: list[int | None] = []
|
|
|
|
async def source_ahistory(config, *, limit=None):
|
|
assert config["configurable"]["thread_id"] == source_thread_id
|
|
history_limits.append(limit)
|
|
return source_history
|
|
|
|
async def unexpected_lineage_read(_config):
|
|
raise AssertionError("unlinked checkpoints must use chronological history")
|
|
|
|
branch_updates: list[dict] = []
|
|
|
|
async def branch_aupdate(config, values, *, as_node=None):
|
|
assert as_node == "branch"
|
|
branch_updates.append(values)
|
|
return {
|
|
"configurable": {
|
|
"thread_id": config["configurable"]["thread_id"],
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": f"branch-{len(branch_updates)}",
|
|
}
|
|
}
|
|
|
|
source_accessor = SimpleNamespace(ahistory=source_ahistory, aget=unexpected_lineage_read)
|
|
branch_accessor = SimpleNamespace(aupdate=branch_aupdate)
|
|
|
|
def build_accessor(_request, *, thread_id, assistant_id=None, checkpoint_id=None):
|
|
assert thread_id == source_thread_id
|
|
return source_accessor, {
|
|
"configurable": {
|
|
"thread_id": source_thread_id,
|
|
"checkpoint_ns": "",
|
|
}
|
|
}
|
|
|
|
def build_mutation_accessor(_request, *, thread_id, as_node, checkpoint_id=None, state_schema=None):
|
|
return branch_accessor, {
|
|
"configurable": {
|
|
"thread_id": thread_id,
|
|
"checkpoint_ns": "",
|
|
}
|
|
}
|
|
|
|
monkeypatch.setattr(threads, "build_checkpoint_state_accessor", build_accessor)
|
|
monkeypatch.setattr(threads, "build_checkpoint_state_mutation_accessor", build_mutation_accessor)
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": source_thread_id, "metadata": {}, "assistant_id": "agent"},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
response = client.post(
|
|
f"/api/threads/{source_thread_id}/branches",
|
|
json={"message_id": "a1", "message_ids": ["a1"]},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["parent_checkpoint_id"] == "ckpt-1"
|
|
assert [[message.id for message in update["messages"].value] for update in branch_updates] == expected_message_ids
|
|
assert history_limits == [
|
|
threads._BRANCH_HISTORY_RAW_SCAN_LIMIT,
|
|
threads._BRANCH_HISTORY_RAW_SCAN_LIMIT,
|
|
threads._BRANCH_HISTORY_RAW_SCAN_LIMIT,
|
|
]
|
|
|
|
|
|
def test_branch_history_scans_budget_for_duration_only_checkpoints() -> None:
|
|
target = SimpleNamespace(
|
|
values={
|
|
"messages": [
|
|
HumanMessage(id="h1", content="Question"),
|
|
AIMessage(id="a1", content="Answer"),
|
|
]
|
|
},
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "source-duration-budget",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": "target",
|
|
}
|
|
},
|
|
metadata={},
|
|
)
|
|
duration_only = [
|
|
SimpleNamespace(
|
|
values={"messages": []},
|
|
config={
|
|
"configurable": {
|
|
"thread_id": "source-duration-budget",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": f"duration-{index}",
|
|
}
|
|
},
|
|
metadata={"writes": {"runtime_run_duration": index}},
|
|
)
|
|
for index in range(threads._BRANCH_HISTORY_SCAN_LIMIT)
|
|
]
|
|
history = [*duration_only, target]
|
|
limits: list[int | None] = []
|
|
|
|
async def ahistory(_config, *, limit=None):
|
|
limits.append(limit)
|
|
return history[:limit]
|
|
|
|
accessor = SimpleNamespace(ahistory=ahistory)
|
|
config = {"configurable": {"thread_id": "source-duration-budget", "checkpoint_ns": ""}}
|
|
|
|
found = asyncio.run(threads._find_branch_checkpoint(accessor, config, {"a1"}))
|
|
targets_latest = asyncio.run(threads._branch_targets_latest_turn(accessor, config, {"a1"}))
|
|
|
|
assert found is target
|
|
assert targets_latest is True
|
|
assert limits == [threads._BRANCH_HISTORY_RAW_SCAN_LIMIT, threads._BRANCH_HISTORY_RAW_SCAN_LIMIT]
|
|
|
|
|
|
def test_branch_thread_real_mutation_graph_finishes_without_scheduling(monkeypatch) -> None:
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
app.state.checkpoint_channel_mode = "delta"
|
|
source_thread_id = "source-real-branch"
|
|
messages = [
|
|
HumanMessage(id="h1", content="First question"),
|
|
AIMessage(id="a1", content="First answer"),
|
|
HumanMessage(id="h2", content="Second question"),
|
|
AIMessage(id="a2", content="Second answer"),
|
|
]
|
|
|
|
def snapshot(checkpoint_id: str, materialized_messages: list[object], *, parent_id: str | None = None) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
values={"messages": materialized_messages},
|
|
config={
|
|
"configurable": {
|
|
"thread_id": source_thread_id,
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": checkpoint_id,
|
|
}
|
|
},
|
|
metadata={},
|
|
parent_config=(
|
|
{
|
|
"configurable": {
|
|
"thread_id": source_thread_id,
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": parent_id,
|
|
}
|
|
}
|
|
if parent_id is not None
|
|
else None
|
|
),
|
|
)
|
|
|
|
source_history = [
|
|
snapshot("ckpt-2", messages, parent_id="ckpt-1"),
|
|
snapshot("ckpt-1", messages[:2], parent_id="ckpt-0"),
|
|
snapshot("ckpt-0", []),
|
|
]
|
|
source_accessor = SimpleNamespace(
|
|
ahistory=AsyncMock(return_value=source_history),
|
|
aget=AsyncMock(side_effect=lambda config: next(item for item in source_history if item.config["configurable"]["checkpoint_id"] == config["configurable"]["checkpoint_id"])),
|
|
)
|
|
|
|
def source_builder(_request, *, thread_id, assistant_id=None, checkpoint_id=None):
|
|
if thread_id != source_thread_id:
|
|
raise AssertionError("fresh branches must use the dedicated mutation graph")
|
|
return source_accessor, {
|
|
"configurable": {
|
|
"thread_id": source_thread_id,
|
|
"checkpoint_ns": "",
|
|
}
|
|
}
|
|
|
|
real_mutation_builder = gateway_services.build_checkpoint_state_mutation_accessor
|
|
monkeypatch.setattr(threads, "build_checkpoint_state_accessor", source_builder)
|
|
monkeypatch.setattr(
|
|
threads,
|
|
"build_checkpoint_state_mutation_accessor",
|
|
real_mutation_builder,
|
|
raising=False,
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": source_thread_id, "metadata": {}, "assistant_id": "agent"},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
response = client.post(
|
|
f"/api/threads/{source_thread_id}/branches",
|
|
json={"message_id": "a1", "message_ids": ["a1"]},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
new_thread_id = response.json()["thread_id"]
|
|
accessor, config = real_mutation_builder(
|
|
SimpleNamespace(app=app),
|
|
thread_id=new_thread_id,
|
|
as_node="branch",
|
|
)
|
|
branch_snapshot = asyncio.run(accessor.aget(config))
|
|
assert [message.id for message in branch_snapshot.values["messages"]] == ["h1", "a1"]
|
|
assert branch_snapshot.next == ()
|
|
assert branch_snapshot.metadata["deerflow_branch"] is True
|
|
assert branch_snapshot.metadata["branch_parent_checkpoint_id"] == "ckpt-1"
|
|
|
|
|
|
def _wire_extension_agent(monkeypatch, app, checkpointer, mode):
|
|
"""Stub only the infra context + assistant factory; keep builders real.
|
|
|
|
The production resolution path stays live: thread record -> assistant_id
|
|
-> resolve_agent_factory -> effective graph (base schema for ``mode`` plus
|
|
a non-identity reducer channel contributed by AgentMiddleware.state_schema).
|
|
"""
|
|
import operator
|
|
from typing import Annotated, NotRequired, TypedDict
|
|
|
|
from langchain.agents import create_agent
|
|
from langchain.agents.middleware import AgentMiddleware
|
|
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
|
|
|
from deerflow.agents.thread_state import get_thread_state_schema
|
|
|
|
class ExtensionState(TypedDict):
|
|
ext_list: NotRequired[Annotated[list[str], operator.add]]
|
|
|
|
class ExtensionMiddleware(AgentMiddleware):
|
|
state_schema = ExtensionState
|
|
|
|
app.state.checkpoint_channel_mode = mode
|
|
model = FakeMessagesListChatModel(responses=[AIMessage(content="ok")])
|
|
|
|
def custom_factory(*, config=None):
|
|
return create_agent(model, middleware=[ExtensionMiddleware()], state_schema=get_thread_state_schema(mode))
|
|
|
|
def default_factory(*, config=None):
|
|
return create_agent(model, state_schema=get_thread_state_schema(mode))
|
|
|
|
def selective_factory(assistant_id):
|
|
# Only the thread's recorded assistant yields the extension graph;
|
|
# unresolved assistant_id must materialize with the default schema so
|
|
# the tests actually guard the resolution boundary.
|
|
return custom_factory if assistant_id == "extension-agent" else default_factory
|
|
|
|
ctx = SimpleNamespace(checkpointer=checkpointer, store=None, checkpoint_channel_mode=mode, app_config=None)
|
|
monkeypatch.setattr(gateway_services, "get_run_context", lambda _request: ctx)
|
|
monkeypatch.setattr(gateway_services, "resolve_agent_factory", selective_factory)
|
|
monkeypatch.setattr(threads, "build_checkpoint_state_accessor", gateway_services.build_checkpoint_state_accessor)
|
|
monkeypatch.setattr(threads, "build_checkpoint_state_mutation_accessor", gateway_services.build_checkpoint_state_mutation_accessor)
|
|
monkeypatch.setattr(threads, "build_thread_checkpoint_state_accessor", gateway_services.build_thread_checkpoint_state_accessor)
|
|
monkeypatch.setattr(threads, "build_thread_checkpoint_state_mutation_accessor", gateway_services.build_thread_checkpoint_state_mutation_accessor)
|
|
monkeypatch.setattr(thread_runs, "build_thread_checkpoint_state_accessor", gateway_services.build_thread_checkpoint_state_accessor)
|
|
return custom_factory
|
|
|
|
|
|
async def _seed_extension_source(checkpointer, custom_factory, mode, source_thread_id):
|
|
accessor = CheckpointStateAccessor.bind(custom_factory(), checkpointer, mode=mode)
|
|
await accessor.aupdate(
|
|
{"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}},
|
|
{"messages": [HumanMessage(id="h1", content="question")], "ext_list": ["merged"]},
|
|
as_node="model",
|
|
)
|
|
await accessor.aupdate(
|
|
{"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}},
|
|
{"messages": [AIMessage(id="a1", content="answer")], "ext_list": ["payload"]},
|
|
as_node="model",
|
|
)
|
|
await accessor.aupdate(
|
|
{"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}},
|
|
{
|
|
"messages": [
|
|
HumanMessage(
|
|
id="h2",
|
|
content="follow-up",
|
|
additional_kwargs={"run_id": "source-run"},
|
|
)
|
|
]
|
|
},
|
|
as_node="model",
|
|
)
|
|
await accessor.aupdate(
|
|
{"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}},
|
|
{"messages": [AIMessage(id="a2", content="follow-up answer")]},
|
|
as_node="model",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["full", "delta"])
|
|
def test_state_endpoints_preserve_extension_reducer_channels(monkeypatch, mode) -> None:
|
|
"""A non-identity middleware reducer channel survives state endpoints.
|
|
|
|
GET /state must return the extension value (resolved via the thread's
|
|
assistant_id), POST /state must replace it, and branch must preserve it
|
|
byte-for-byte by copying reducer channels with Overwrite semantics. The
|
|
copied pre-user checkpoint must also remain materializable for regenerate.
|
|
"""
|
|
app, _store, checkpointer = _build_thread_app()
|
|
app.include_router(thread_runs.router)
|
|
custom_factory = _wire_extension_agent(monkeypatch, app, checkpointer, mode)
|
|
|
|
async def list_messages(_thread_id: str, *, limit: int, **_kwargs) -> list[dict]:
|
|
assert limit == thread_runs.REGENERATE_HISTORY_SCAN_LIMIT
|
|
return []
|
|
|
|
async def list_by_thread(_thread_id: str, *, user_id=None, limit: int = 100) -> list:
|
|
return []
|
|
|
|
app.state.run_event_store = SimpleNamespace(list_messages=list_messages)
|
|
app.state.run_manager.list_by_thread = list_by_thread
|
|
|
|
recorded_updates: list[dict] = []
|
|
real_mutation_builder = gateway_services.build_checkpoint_state_mutation_accessor
|
|
|
|
def recording_mutation_builder(request, *, thread_id, as_node, checkpoint_id=None, state_schema=None):
|
|
accessor, config = real_mutation_builder(request, thread_id=thread_id, as_node=as_node, checkpoint_id=checkpoint_id, state_schema=state_schema)
|
|
original_aupdate = accessor.aupdate
|
|
|
|
async def recording_aupdate(config, values, *, as_node=None):
|
|
recorded_updates.append(dict(values))
|
|
return await original_aupdate(config, values, as_node=as_node)
|
|
|
|
accessor.aupdate = recording_aupdate
|
|
return accessor, config
|
|
|
|
monkeypatch.setattr(gateway_services, "build_checkpoint_state_mutation_accessor", recording_mutation_builder)
|
|
monkeypatch.setattr(threads, "build_checkpoint_state_mutation_accessor", recording_mutation_builder)
|
|
|
|
source_thread_id = "extension-source"
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": source_thread_id, "metadata": {}, "assistant_id": "extension-agent"},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
|
|
# Seed after creation: create_thread writes an empty head checkpoint.
|
|
asyncio.run(_seed_extension_source(checkpointer, custom_factory, mode, source_thread_id))
|
|
|
|
read_response = client.get(f"/api/threads/{source_thread_id}/state")
|
|
assert read_response.status_code == 200, read_response.text
|
|
assert read_response.json()["values"]["ext_list"] == ["merged", "payload"]
|
|
|
|
update_response = client.post(
|
|
f"/api/threads/{source_thread_id}/state",
|
|
json={"values": {"ext_list": ["replaced"]}},
|
|
)
|
|
assert update_response.status_code == 200, update_response.text
|
|
assert update_response.json()["values"]["ext_list"] == ["replaced"]
|
|
|
|
branch_response = client.post(
|
|
f"/api/threads/{source_thread_id}/branches",
|
|
json={"message_id": "a2", "message_ids": ["a2"]},
|
|
)
|
|
assert branch_response.status_code == 200, branch_response.text
|
|
branch_thread_id = branch_response.json()["thread_id"]
|
|
|
|
prepare_response = client.post(
|
|
f"/api/threads/{branch_thread_id}/runs/regenerate/prepare",
|
|
json={"message_id": "a2"},
|
|
)
|
|
assert prepare_response.status_code == 200, prepare_response.text
|
|
|
|
# The branch write must copy every reducer channel with replace semantics.
|
|
branch_update = recorded_updates[-1]
|
|
assert isinstance(branch_update["ext_list"], Overwrite)
|
|
assert branch_update["ext_list"].value == ["replaced"]
|
|
assert isinstance(branch_update["messages"], Overwrite)
|
|
|
|
async def materialize(thread_id):
|
|
accessor = CheckpointStateAccessor.bind(custom_factory(), checkpointer, mode=mode)
|
|
snapshot = await accessor.aget({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}})
|
|
return snapshot.values
|
|
|
|
branch_values = asyncio.run(materialize(branch_thread_id))
|
|
assert branch_values["ext_list"] == ["replaced"]
|
|
assert [message.id for message in branch_values["messages"]] == ["h1", "a1", "h2", "a2"]
|
|
|
|
prepared = prepare_response.json()
|
|
assert prepared["target_run_id"] == "source-run"
|
|
assert prepared["input"]["messages"][0]["id"] == "h2"
|
|
base_accessor = CheckpointStateAccessor.bind(custom_factory(), checkpointer, mode=mode)
|
|
base_values = asyncio.run(
|
|
base_accessor.aget(
|
|
{
|
|
"configurable": {
|
|
"thread_id": branch_thread_id,
|
|
"checkpoint_ns": prepared["checkpoint"]["checkpoint_ns"],
|
|
"checkpoint_id": prepared["checkpoint"]["checkpoint_id"],
|
|
}
|
|
}
|
|
)
|
|
).values
|
|
assert [message.id for message in base_values["messages"]] == ["h1", "a1"]
|
|
|
|
|
|
async def _seed_branch_history_source(checkpointer, custom_factory, mode, source_thread_id):
|
|
"""Seed a completed turn whose history includes hidden and tool messages."""
|
|
accessor = CheckpointStateAccessor.bind(custom_factory(), checkpointer, mode=mode)
|
|
config = {"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}}
|
|
await accessor.aupdate(
|
|
config,
|
|
{
|
|
"messages": [
|
|
HumanMessage(id="h1", content="question"),
|
|
HumanMessage(id="h-hidden", content="internal", additional_kwargs={"hide_from_ui": True}),
|
|
]
|
|
},
|
|
as_node="model",
|
|
)
|
|
await accessor.aupdate(
|
|
config,
|
|
{
|
|
"messages": [
|
|
ToolMessage(id="t1", content="tool output", tool_call_id="call-1"),
|
|
AIMessage(id="a1", content="answer"),
|
|
]
|
|
},
|
|
as_node="model",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["full", "delta"])
|
|
def test_branch_seeds_run_events_with_parent_history(monkeypatch, mode) -> None:
|
|
"""Branching must seed the branch's run-event feed with the parent history.
|
|
|
|
The thread feed (``GET /messages`` / ``/messages/page``) reads the
|
|
run-event store, not checkpoints; without seeding, a fresh branch has no
|
|
message rows, so the inherited history vanishes from the UI as soon as
|
|
the branch's first run refreshes the feed (#4380 problem 2).
|
|
"""
|
|
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
|
|
|
app, _store, checkpointer = _build_thread_app()
|
|
custom_factory = _wire_extension_agent(monkeypatch, app, checkpointer, mode)
|
|
event_store = MemoryRunEventStore()
|
|
app.state.run_event_store = event_store
|
|
source_thread_id = f"branch-history-source-{mode}"
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": source_thread_id, "metadata": {}, "assistant_id": "extension-agent"},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
|
|
asyncio.run(_seed_branch_history_source(checkpointer, custom_factory, mode, source_thread_id))
|
|
|
|
branch_response = client.post(
|
|
f"/api/threads/{source_thread_id}/branches",
|
|
json={"message_id": "a1", "message_ids": ["a1"]},
|
|
)
|
|
assert branch_response.status_code == 200, branch_response.text
|
|
branch_thread_id = branch_response.json()["thread_id"]
|
|
|
|
rows = asyncio.run(event_store.list_messages(branch_thread_id, user_id=None))
|
|
|
|
# The visible parent history is seeded in order; hidden messages are not.
|
|
assert [row["content"]["id"] for row in rows] == ["h1", "t1", "a1"]
|
|
assert [row["event_type"] for row in rows] == ["llm.human.input", "llm.tool.result", "llm.ai.response"]
|
|
assert all(row["category"] == "message" for row in rows)
|
|
# One synthetic run per inherited turn (#4458): this source has a single turn.
|
|
assert all(row["run_id"] == f"branch-seed-{branch_thread_id}-1" for row in rows)
|
|
assert all((row.get("metadata") or {}).get("branch_seed") is True for row in rows)
|
|
seqs = [row["seq"] for row in rows]
|
|
assert seqs == sorted(seqs)
|
|
assert branch_response.json()["history_seed_mode"] == "seeded"
|
|
|
|
# The parent thread's feed stays untouched.
|
|
assert asyncio.run(event_store.list_messages(source_thread_id, user_id=None)) == []
|
|
|
|
|
|
def test_branch_history_seed_failure_keeps_branch_usable(monkeypatch) -> None:
|
|
"""A seeding failure must degrade, not fail the branch (best-effort)."""
|
|
|
|
class _ExplodingStore:
|
|
async def put_batch(self, events):
|
|
raise RuntimeError("event store down")
|
|
|
|
app, _store, checkpointer = _build_thread_app()
|
|
custom_factory = _wire_extension_agent(monkeypatch, app, checkpointer, "full")
|
|
app.state.run_event_store = _ExplodingStore()
|
|
source_thread_id = "branch-history-source-failure"
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": source_thread_id, "metadata": {}, "assistant_id": "extension-agent"},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
|
|
asyncio.run(_seed_branch_history_source(checkpointer, custom_factory, "full", source_thread_id))
|
|
|
|
branch_response = client.post(
|
|
f"/api/threads/{source_thread_id}/branches",
|
|
json={"message_id": "a1", "message_ids": ["a1"]},
|
|
)
|
|
|
|
assert branch_response.status_code == 200, branch_response.text
|
|
assert branch_response.json()["history_seed_mode"] == "failed"
|
|
|
|
|
|
async def _seed_union_channel_source(checkpointer, custom_factory, mode, source_thread_id):
|
|
"""Seed a completed turn plus Union-typed reducer channels (sandbox/goal/todos)."""
|
|
accessor = CheckpointStateAccessor.bind(custom_factory(), checkpointer, mode=mode)
|
|
config = {"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}}
|
|
await accessor.aupdate(
|
|
config,
|
|
{"messages": [HumanMessage(id="h1", content="question")], "goal": {"objective": "ship the fix"}},
|
|
as_node="model",
|
|
)
|
|
await accessor.aupdate(
|
|
config,
|
|
{
|
|
"messages": [AIMessage(id="a1", content="answer")],
|
|
"todos": [{"content": "write tests", "status": "pending"}],
|
|
"sandbox": {"sandbox_id": "local:parent-thread"},
|
|
"thread_data": {"workspace_path": "/parent/workspace"},
|
|
},
|
|
as_node="model",
|
|
)
|
|
|
|
|
|
def _branch_union_channel_thread(monkeypatch, mode):
|
|
"""Drive POST /branches on a source seeded with Union-typed channels; return branch values."""
|
|
app, _store, checkpointer = _build_thread_app()
|
|
custom_factory = _wire_extension_agent(monkeypatch, app, checkpointer, mode)
|
|
source_thread_id = f"union-branch-source-{mode}"
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": source_thread_id, "metadata": {}, "assistant_id": "extension-agent"},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
|
|
asyncio.run(_seed_union_channel_source(checkpointer, custom_factory, mode, source_thread_id))
|
|
|
|
branch_response = client.post(
|
|
f"/api/threads/{source_thread_id}/branches",
|
|
json={"message_id": "a1", "message_ids": ["a1"]},
|
|
)
|
|
assert branch_response.status_code == 200, branch_response.text
|
|
branch_thread_id = branch_response.json()["thread_id"]
|
|
|
|
async def materialize():
|
|
accessor = CheckpointStateAccessor.bind(custom_factory(), checkpointer, mode=mode)
|
|
snapshot = await accessor.aget({"configurable": {"thread_id": branch_thread_id, "checkpoint_ns": ""}})
|
|
return snapshot.values
|
|
|
|
return asyncio.run(materialize())
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["full", "delta"])
|
|
def test_branch_copies_union_typed_reducer_channels_as_plain_values(monkeypatch, mode) -> None:
|
|
"""Branching must not persist Overwrite wrappers into the fresh thread (#4380).
|
|
|
|
Union-typed reducer channels (``goal``, ``todos``, ``promoted``,
|
|
``sandbox``) have no constructible default, so they start MISSING on the
|
|
branch thread; an ``Overwrite`` first write that isn't unwrapped is stored
|
|
literally and the next consumer crashes with ``TypeError: 'Overwrite'
|
|
object is not subscriptable``.
|
|
"""
|
|
branch_values = _branch_union_channel_thread(monkeypatch, mode)
|
|
|
|
# The exact crash shape from #4380: subscripting the copied channel value.
|
|
assert branch_values["goal"]["objective"] == "ship the fix"
|
|
assert branch_values["todos"] == [{"content": "write tests", "status": "pending"}]
|
|
assert not any(isinstance(value, Overwrite) for value in branch_values.values())
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["full", "delta"])
|
|
def test_branch_does_not_inherit_thread_scoped_channels(monkeypatch, mode) -> None:
|
|
"""The branch must acquire its own sandbox and thread paths, not the parent's.
|
|
|
|
``sandbox.sandbox_id`` binds path mappings and the release lifecycle to
|
|
the parent thread, so inheriting it would make the branch read/write the
|
|
parent's workspace and release the parent's sandbox after its first run;
|
|
``thread_data`` is recomputed from the branch's own thread_id by
|
|
ThreadDataMiddleware on every run.
|
|
"""
|
|
branch_values = _branch_union_channel_thread(monkeypatch, mode)
|
|
|
|
assert branch_values.get("sandbox") is None
|
|
assert branch_values.get("thread_data") is None
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["full", "delta"])
|
|
def test_update_thread_state_overwrite_into_never_written_channel(monkeypatch, mode) -> None:
|
|
"""POST /state must store a plain value when the reducer channel was never written.
|
|
|
|
Same mechanism as the branch case (#4380): ``goal`` starts MISSING on a
|
|
thread that never wrote it, and the endpoint's replace-style ``Overwrite``
|
|
wrapping must not be persisted literally.
|
|
"""
|
|
app, _store, checkpointer = _build_thread_app()
|
|
custom_factory = _wire_extension_agent(monkeypatch, app, checkpointer, mode)
|
|
source_thread_id = f"never-written-goal-{mode}"
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": source_thread_id, "metadata": {}, "assistant_id": "extension-agent"},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
|
|
asyncio.run(_seed_extension_source(checkpointer, custom_factory, mode, source_thread_id))
|
|
|
|
update_response = client.post(
|
|
f"/api/threads/{source_thread_id}/state",
|
|
json={"values": {"goal": {"objective": "finish"}}},
|
|
)
|
|
assert update_response.status_code == 200, update_response.text
|
|
assert update_response.json()["values"]["goal"] == {"objective": "finish"}
|
|
|
|
read_response = client.get(f"/api/threads/{source_thread_id}/state")
|
|
assert read_response.status_code == 200, read_response.text
|
|
assert read_response.json()["values"]["goal"] == {"objective": "finish"}
|
|
|
|
|
|
def test_update_thread_state_rejects_unknown_state_fields(monkeypatch) -> None:
|
|
"""Unknown fields fail 422 instead of a false-success 200."""
|
|
app, _store, checkpointer = _build_thread_app()
|
|
custom_factory = _wire_extension_agent(monkeypatch, app, checkpointer, "full")
|
|
source_thread_id = "extension-source-422"
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": source_thread_id, "metadata": {}, "assistant_id": "extension-agent"},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
|
|
asyncio.run(_seed_extension_source(checkpointer, custom_factory, "full", source_thread_id))
|
|
|
|
response = client.post(
|
|
f"/api/threads/{source_thread_id}/state",
|
|
json={"values": {"not_a_state_field": 1}},
|
|
)
|
|
|
|
assert response.status_code == 422, response.text
|
|
assert "not_a_state_field" in response.json()["detail"]
|
|
|
|
|
|
def test_branch_display_name_strips_legacy_branch_prefix_only_for_branch_sources() -> None:
|
|
assert threads._default_branch_display_name("Original chat") == "Original chat"
|
|
assert threads._default_branch_display_name("Branch: Original chat") == "Branch: Original chat"
|
|
assert threads._default_branch_display_name("Branch: Branch: Original chat", source_is_branch=True) == "Original chat"
|
|
|
|
|
|
def test_branch_thread_rejects_sidecar_threads() -> None:
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": "sidecar-thread", "metadata": {"deerflow_sidecar": True}},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
|
|
response = client.post(
|
|
"/api/threads/sidecar-thread/branches",
|
|
json={"message_id": "ai-1", "message_ids": ["ai-1"]},
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert "main conversation" in response.json()["detail"]
|
|
|
|
|
|
def test_branch_thread_rejects_non_assistant_targets() -> None:
|
|
app, _store, checkpointer = _build_thread_app()
|
|
source_thread_id = "source-human-target"
|
|
human = HumanMessage(id="human-1", content="Question")
|
|
ai = AIMessage(id="ai-1", content="Answer")
|
|
|
|
async def _seed(parent_config: dict) -> None:
|
|
after_human = await _write_checkpoint(checkpointer, source_thread_id, str(uuid6()), [human], step=1, parent_config=parent_config)
|
|
await _write_checkpoint(checkpointer, source_thread_id, str(uuid6()), [human, ai], step=2, parent_config=after_human)
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post("/api/threads", json={"thread_id": source_thread_id, "metadata": {}})
|
|
assert created.status_code == 200, created.text
|
|
initial = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}}))
|
|
assert initial is not None
|
|
asyncio.run(_seed(initial.config))
|
|
|
|
response = client.post(
|
|
f"/api/threads/{source_thread_id}/branches",
|
|
json={"message_id": "human-1", "message_ids": ["human-1"]},
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert "can no longer be branched" in response.json()["detail"]
|
|
|
|
|
|
def test_branch_thread_best_effort_copies_current_workspace(tmp_path) -> None:
|
|
paths = Paths(tmp_path)
|
|
app, _store, checkpointer = _build_thread_app()
|
|
source_thread_id = "source-with-files"
|
|
user_id = "branch-user"
|
|
|
|
source_user_data = paths.sandbox_user_data_dir(source_thread_id, user_id=user_id)
|
|
source_outputs = paths.sandbox_outputs_dir(source_thread_id, user_id=user_id)
|
|
source_uploads = paths.sandbox_uploads_dir(source_thread_id, user_id=user_id)
|
|
source_outputs.mkdir(parents=True, exist_ok=True)
|
|
source_uploads.mkdir(parents=True, exist_ok=True)
|
|
(source_outputs / "result.txt").write_text("answer", encoding="utf-8")
|
|
(source_uploads / ".upload-stale.part").write_text("partial", encoding="utf-8")
|
|
|
|
human = HumanMessage(id="human-file", content="Make a file")
|
|
ai = AIMessage(id="ai-file", content="Done")
|
|
|
|
async def _seed(parent_config: dict) -> None:
|
|
after_human = await _write_checkpoint(checkpointer, source_thread_id, str(uuid6()), [human], step=1, parent_config=parent_config)
|
|
await _write_checkpoint(checkpointer, source_thread_id, str(uuid6()), [human, ai], step=2, parent_config=after_human)
|
|
|
|
with (
|
|
patch("app.gateway.routers.threads.get_paths", return_value=paths),
|
|
patch("app.gateway.routers.threads.get_effective_user_id", return_value=user_id),
|
|
TestClient(app) as client,
|
|
):
|
|
created = client.post("/api/threads", json={"thread_id": source_thread_id, "metadata": {}})
|
|
assert created.status_code == 200, created.text
|
|
initial = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}}))
|
|
assert initial is not None
|
|
asyncio.run(_seed(initial.config))
|
|
|
|
response = client.post(
|
|
f"/api/threads/{source_thread_id}/branches",
|
|
json={"message_id": "ai-file", "message_ids": ["ai-file"]},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert body["workspace_clone_mode"] == "current_thread_best_effort"
|
|
|
|
target_user_data = paths.sandbox_user_data_dir(body["thread_id"], user_id=user_id)
|
|
assert target_user_data.exists()
|
|
assert (target_user_data / "outputs" / "result.txt").read_text(encoding="utf-8") == "answer"
|
|
assert not (target_user_data / "uploads" / ".upload-stale.part").exists()
|
|
assert source_user_data.exists()
|
|
|
|
|
|
def test_branch_thread_from_historical_turn_skips_workspace_clone(tmp_path) -> None:
|
|
"""Branching from a non-latest turn must not clone the current workspace.
|
|
|
|
Workspace files are not checkpointed, so cloning them onto a branch rooted at
|
|
an older turn would leak files created after that turn (regression for the
|
|
historical-turn workspace-leak review on PR #3950).
|
|
"""
|
|
paths = Paths(tmp_path)
|
|
app, _store, checkpointer = _build_thread_app()
|
|
source_thread_id = "source-historical"
|
|
user_id = "branch-user"
|
|
|
|
source_outputs = paths.sandbox_outputs_dir(source_thread_id, user_id=user_id)
|
|
source_outputs.mkdir(parents=True, exist_ok=True)
|
|
# ``future.txt`` only exists in the current (latest) workspace timeline.
|
|
(source_outputs / "future.txt").write_text("future", encoding="utf-8")
|
|
|
|
human_1 = HumanMessage(id="human-1", content="First question")
|
|
ai_1 = AIMessage(id="ai-1", content="First answer")
|
|
human_2 = HumanMessage(id="human-2", content="Second question")
|
|
ai_2 = AIMessage(id="ai-2", content="Second answer")
|
|
|
|
async def _seed(parent_config: dict) -> dict:
|
|
after_human_1 = await _write_checkpoint(checkpointer, source_thread_id, str(uuid6()), [human_1], step=1, parent_config=parent_config)
|
|
after_ai_1 = await _write_checkpoint(checkpointer, source_thread_id, str(uuid6()), [human_1, ai_1], step=2, parent_config=after_human_1)
|
|
after_human_2 = await _write_checkpoint(
|
|
checkpointer,
|
|
source_thread_id,
|
|
str(uuid6()),
|
|
[human_1, ai_1, human_2],
|
|
step=3,
|
|
parent_config=after_ai_1,
|
|
)
|
|
await _write_checkpoint(
|
|
checkpointer,
|
|
source_thread_id,
|
|
str(uuid6()),
|
|
[human_1, ai_1, human_2, ai_2],
|
|
step=4,
|
|
parent_config=after_human_2,
|
|
)
|
|
return after_ai_1
|
|
|
|
with (
|
|
patch("app.gateway.routers.threads.get_paths", return_value=paths),
|
|
patch("app.gateway.routers.threads.get_effective_user_id", return_value=user_id),
|
|
TestClient(app) as client,
|
|
):
|
|
created = client.post("/api/threads", json={"thread_id": source_thread_id, "metadata": {}})
|
|
assert created.status_code == 200, created.text
|
|
initial = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}}))
|
|
assert initial is not None
|
|
target_checkpoint_config = asyncio.run(_seed(initial.config))
|
|
|
|
response = client.post(
|
|
f"/api/threads/{source_thread_id}/branches",
|
|
json={"message_id": "ai-1", "message_ids": ["ai-1"]},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert body["parent_checkpoint_id"] == target_checkpoint_config["configurable"]["checkpoint_id"]
|
|
assert body["workspace_clone_mode"] == "skipped_historical_turn"
|
|
|
|
target_user_data = paths.sandbox_user_data_dir(body["thread_id"], user_id=user_id)
|
|
assert not target_user_data.exists()
|
|
|
|
|
|
# ── Metadata filter validation at API boundary ────────────────────────────────
|
|
|
|
|
|
def test_search_threads_rejects_invalid_key_at_api_boundary() -> None:
|
|
"""Keys that don't match [A-Za-z0-9_-]+ are rejected by the Pydantic
|
|
validator on ThreadSearchRequest.metadata — 422 from both backends.
|
|
"""
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post("/api/threads/search", json={"metadata": {"bad;key": "x"}})
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_search_threads_rejects_unsupported_value_type_at_api_boundary() -> None:
|
|
"""Value types outside (None, bool, int, float, str) are rejected."""
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post("/api/threads/search", json={"metadata": {"env": ["a", "b"]}})
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_search_threads_returns_400_for_backend_invalid_metadata_filter() -> None:
|
|
"""If the backend still raises InvalidMetadataFilterError (defense in
|
|
depth), the handler surfaces it as HTTP 400.
|
|
"""
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
thread_store = app.state.thread_store
|
|
|
|
async def _raise(**kwargs):
|
|
raise InvalidMetadataFilterError("rejected")
|
|
|
|
with TestClient(app) as client:
|
|
with patch.object(thread_store, "search", side_effect=_raise):
|
|
response = client.post("/api/threads/search", json={"metadata": {"valid_key": "x"}})
|
|
|
|
assert response.status_code == 400
|
|
assert "rejected" in response.json()["detail"]
|
|
|
|
|
|
def test_search_threads_succeeds_with_valid_metadata() -> None:
|
|
"""Sanity check: valid metadata passes through without error."""
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post("/api/threads/search", json={"metadata": {"env": "prod"}})
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
# ── update_thread_state: each call inserts a new checkpoint (regression) ───────
|
|
|
|
|
|
def test_update_thread_state_overwrites_reducer_fields_and_writes_last_values_directly(monkeypatch) -> None:
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
update_calls: list[tuple[dict, dict, str | None]] = []
|
|
updated_config = {
|
|
"configurable": {
|
|
"thread_id": "state-overwrite",
|
|
"checkpoint_ns": "",
|
|
"checkpoint_id": "ckpt-updated",
|
|
}
|
|
}
|
|
snapshot = SimpleNamespace(
|
|
values={
|
|
"messages": [{"type": "human", "id": "h1", "content": "replacement"}],
|
|
"artifacts": ["artifact-1"],
|
|
"title": "Renamed",
|
|
},
|
|
config=updated_config,
|
|
parent_config={"configurable": {"checkpoint_id": "ckpt-original"}},
|
|
metadata={"source": "update", "step": 1},
|
|
next=(),
|
|
tasks=(),
|
|
created_at="2026-07-18T00:00:00+00:00",
|
|
)
|
|
|
|
async def aupdate(config, values, *, as_node=None):
|
|
update_calls.append((config, values, as_node))
|
|
return updated_config
|
|
|
|
accessor = SimpleNamespace(
|
|
aupdate=aupdate,
|
|
aget=AsyncMock(return_value=snapshot),
|
|
)
|
|
|
|
async def build_accessor(_request, *, thread_id, as_node, checkpoint_id=None):
|
|
assert thread_id == "state-overwrite"
|
|
return accessor, {
|
|
"configurable": {
|
|
"thread_id": thread_id,
|
|
"checkpoint_ns": "",
|
|
**({"checkpoint_id": checkpoint_id} if checkpoint_id else {}),
|
|
}
|
|
}
|
|
|
|
monkeypatch.setattr(threads, "build_thread_checkpoint_state_mutation_accessor", build_accessor)
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": "state-overwrite", "metadata": {}},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
response = client.post(
|
|
"/api/threads/state-overwrite/state",
|
|
json={
|
|
"values": {
|
|
"messages": [{"type": "human", "id": "h1", "content": "replacement"}],
|
|
"artifacts": ["artifact-1"],
|
|
"title": "Renamed",
|
|
}
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert len(update_calls) == 1
|
|
read_config, updates, as_node = update_calls[0]
|
|
assert read_config["configurable"]["thread_id"] == "state-overwrite"
|
|
assert isinstance(updates["messages"], Overwrite)
|
|
assert updates["messages"].value[0]["id"] == "h1"
|
|
assert isinstance(updates["artifacts"], Overwrite)
|
|
assert updates["artifacts"].value == ["artifact-1"]
|
|
assert updates["title"] == "Renamed"
|
|
assert as_node == "manual_state_update"
|
|
accessor.aget.assert_awaited_once_with(updated_config)
|
|
assert response.json()["checkpoint_id"] == "ckpt-updated"
|
|
|
|
|
|
def test_update_thread_state_real_mutation_graph_finishes_without_scheduling(monkeypatch) -> None:
|
|
app, _store, _checkpointer = _build_thread_app()
|
|
app.state.checkpoint_channel_mode = "delta"
|
|
real_mutation_builder = gateway_services.build_checkpoint_state_mutation_accessor
|
|
|
|
async def mutation_boundary(request, *, thread_id, as_node, checkpoint_id=None):
|
|
# No real assistant graph in this unit context: the boundary falls
|
|
# back to the base schema while writes still use the mutation graph.
|
|
return real_mutation_builder(request, thread_id=thread_id, as_node=as_node, checkpoint_id=checkpoint_id)
|
|
|
|
monkeypatch.setattr(threads, "build_thread_checkpoint_state_mutation_accessor", mutation_boundary)
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": "state-real-mutation", "metadata": {}},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
response = client.post(
|
|
"/api/threads/state-real-mutation/state",
|
|
json={
|
|
"values": {
|
|
"messages": [{"type": "human", "id": "h1", "content": "replacement"}],
|
|
"artifacts": ["artifact-1"],
|
|
"title": "Renamed",
|
|
}
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert [message["id"] for message in body["values"]["messages"]] == ["h1"]
|
|
assert body["values"]["artifacts"] == ["artifact-1"]
|
|
assert body["values"]["title"] == "Renamed"
|
|
assert body["next"] == []
|
|
|
|
|
|
@pytest.mark.parametrize("missing_checkpoint_id", ["does-not-exist", ""])
|
|
def test_update_thread_state_rejects_missing_explicit_checkpoint_without_writing(
|
|
missing_checkpoint_id: str,
|
|
) -> None:
|
|
app, _store, checkpointer = _build_thread_app()
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post(
|
|
"/api/threads",
|
|
json={"thread_id": "state-missing-checkpoint", "metadata": {}},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
response = client.post(
|
|
"/api/threads/state-missing-checkpoint/state",
|
|
json={
|
|
"checkpoint_id": missing_checkpoint_id,
|
|
"values": {"title": "Must not be written"},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 404, response.text
|
|
|
|
async def collect_checkpoint_ids():
|
|
return [
|
|
item.config["configurable"]["checkpoint_id"]
|
|
async for item in checkpointer.alist(
|
|
{
|
|
"configurable": {
|
|
"thread_id": "state-missing-checkpoint",
|
|
"checkpoint_ns": "",
|
|
}
|
|
}
|
|
)
|
|
]
|
|
|
|
checkpoint_ids = asyncio.run(collect_checkpoint_ids())
|
|
assert missing_checkpoint_id not in checkpoint_ids
|
|
assert len(checkpoint_ids) == 1
|
|
|
|
|
|
def test_update_thread_state_inserts_new_checkpoint_each_call() -> None:
|
|
"""Each ``POST /state`` must INSERT a distinct, time-ordered checkpoint.
|
|
|
|
Regression for the in-place REPLACE bug: before the fix the new
|
|
checkpoint reused the previous checkpoint["id"], so InMemorySaver/SQLite
|
|
overwrote the existing row and history never grew. The fix assigns a
|
|
fresh uuid6 to checkpoint["id"] before aput.
|
|
"""
|
|
app, _store, checkpointer = _build_thread_app()
|
|
|
|
with TestClient(app) as client:
|
|
created = client.post("/api/threads", json={"metadata": {}})
|
|
assert created.status_code == 200, created.text
|
|
thread_id = created.json()["thread_id"]
|
|
|
|
r1 = client.post(f"/api/threads/{thread_id}/state", json={"values": {"title": "First"}})
|
|
assert r1.status_code == 200, r1.text
|
|
r2 = client.post(f"/api/threads/{thread_id}/state", json={"values": {"title": "Second"}})
|
|
assert r2.status_code == 200, r2.text
|
|
|
|
import asyncio
|
|
|
|
async def _collect():
|
|
return [cp async for cp in checkpointer.alist({"configurable": {"thread_id": thread_id}})]
|
|
|
|
history = asyncio.run(_collect())
|
|
|
|
# 1 empty checkpoint from create_thread + 1 per update call.
|
|
assert len(history) >= 3, f"expected >=3 checkpoints, got {len(history)}"
|
|
|
|
ids = [cp.config["configurable"]["checkpoint_id"] for cp in history]
|
|
assert len(ids) == len(set(ids)), f"duplicate checkpoint ids: {ids}"
|
|
# alist() returns newest-first; uuid6 is time-ordered so newest > oldest.
|
|
assert ids[0] > ids[-1], f"checkpoint ids not time-ordered (uuid4 instead of uuid6?): {ids}"
|
|
|
|
# aput must PRESERVE the endpoint-assigned checkpoint["id"], not mint its own
|
|
# and discard the payload's. If it generated a fresh id internally the fix
|
|
# would be a no-op (the bug would never have existed). Assert the id returned
|
|
# in each response round-tripped into the persisted history, and that the two
|
|
# update writes kept the endpoint's uuid6 time-ordering through aput.
|
|
resp_ids = [r1.json()["checkpoint_id"], r2.json()["checkpoint_id"]]
|
|
assert all(cid is not None for cid in resp_ids), f"response missing checkpoint_id: {resp_ids}"
|
|
assert set(resp_ids) <= set(ids), f"aput discarded endpoint-assigned id: returned {resp_ids}, stored {ids}"
|
|
assert resp_ids[1] > resp_ids[0], f"endpoint-assigned uuid6 not preserved/ordered through aput: {resp_ids}"
|