fix(gateway): preserve regenerate state in branched threads (#4358)

* fix(gateway): preserve regenerate state in branched threads

* test(gateway): isolate branch regenerate regression config

* fix(gateway): preserve branching for legacy histories

* fix(gateway): harden branch regenerate lineage

* docs(gateway): clarify branch checkpoint behavior

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Huixin615 2026-07-24 08:57:48 +08:00 committed by GitHub
parent 959f052750
commit fbc1463809
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1104 additions and 98 deletions

View File

@ -693,7 +693,7 @@ Interrupted first-turn runs still persist a fallback conversation title, so stop
Streaming Markdown responses animate only newly arrived words; text that is already visible is not faded out and replayed when the next chunk extends the same block.
In the Web UI, completed assistant turns can be branched into a new main conversation. The new thread starts from that turn's checkpoint. Because workspace files are not checkpointed, the branch only receives a best-effort copy of the current workspace when you branch from the latest turn; branching from an older turn keeps just the restored message history so the branch never inherits files that were created in a later part of the conversation.
In the Web UI, completed assistant turns can be branched into a new main conversation. The new thread starts from that turn's checkpoint and keeps the preceding replay checkpoint, so the branched response can be regenerated immediately. Legacy or imported histories without checkpoint parent links use a bounded chronological fallback; if no earlier replay checkpoint exists, branching still succeeds with the legacy single-checkpoint shape, while regeneration remains unavailable for that inherited response. Existing single-checkpoint branches are left unchanged rather than attempting an unsafe checkpoint copy. Because workspace files are not checkpointed, the branch only receives a best-effort copy of the current workspace when you branch from the latest turn; branching from an older turn keeps just the restored message history so the branch never inherits files that were created in a later part of the conversation.
The Web UI reports completed task time once per run. This is total wall-clock time—including model reasoning, tool calls, and waiting—not a per-step or model-only thinking duration. Reasoning content remains available through its own separate disclosure.

View File

@ -548,7 +548,7 @@ Tools 也是同样的思路。DeerFlow 自带一组核心工具:网页搜索
Gateway 生成后续建议时,现在会先把普通字符串输出和 block/list 风格的富文本内容统一归一化,再去解析 JSON 数组响应,因此不同 provider 的内容包装方式不会再悄悄把建议吞掉。
Web UI 支持从已完成的 assistant 回复分叉出一个新的主对话。新 thread 会从该回复对应的 checkpoint 开始,并尽力复制当前 thread 的工作区文件。
Web UI 支持从已完成的 assistant 回复分叉出一个新的主对话。新 thread 会保留该轮回复的 checkpoint 以及用户消息之前的重放 checkpoint因此分叉后可以立即重新生成该回复。对于缺少 checkpoint 父链接的旧历史或导入历史Gateway 会进行有界的时间顺序查找;如果不存在更早的重放 checkpoint分叉仍会按旧版单-checkpoint 形态成功创建,但无法重新生成继承的回复。已有的单-checkpoint 分叉会保持不变,不会通过不安全的 checkpoint 复制尝试修复。只有从最新回合分叉时才会尽力复制当前 thread 的工作区文件;从历史回合分叉不会带入后续时间线创建的文件。
```text
# sandbox 容器内的路径

View File

@ -417,7 +417,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores `
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`); `POST /reload` - admin-only process-local prompt-cache invalidation after trusted external filesystem changes |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint and, when an addressable pre-user replay checkpoint exists, materialize it into the branch namespace so the inherited response remains regeneratable. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
| **Input Polish** (`/api/input-polish`) | `POST /` - rewrite a composer draft before it is sent. This is a short authenticated `runs:create` LLM request using `input_polish` config; it does not create a LangGraph run, persist a message, or modify thread state. Shares the non-graph one-shot LLM path (`deerflow.utils.oneshot_llm.run_oneshot_llm`) with the suggestions route so model build + Langfuse metadata + invoke stay in one place; validates the same stripped view of the draft it sends to the model, and preserves literal `<think>` substrings in the rewrite (`strip_think_blocks(truncate_unclosed=False)`) |
@ -449,6 +449,16 @@ metadata only.
Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runtime, all other `/api/*` → Gateway REST APIs.
**Branch/regenerate checkpoint invariant**: `app/gateway/checkpoint_lineage.py`
walks `parent_config` rather than globally ordered checkpoint history so replay
anchors stay on the selected lineage after regenerations create sibling branches.
New conversation branches persist the pre-user replay anchor before their visible
head through the state mutation graph, which preserves materialized state in both
full and delta checkpoint modes. Only an explicitly absent legacy parent link may
use chronological compatibility lookup; cycles, dangling links, and depth-limit
exhaustion fail closed. Existing single-checkpoint branches are never repaired by
copying a raw checkpoint because delta state is not self-contained in one tuple.
### Sandbox System (`packages/harness/deerflow/sandbox/`)
**Interface**: Abstract `Sandbox` with `execute_command(command, env=None)`, `read_file`, `write_file`, `list_dir`. The optional `env` injects per-call environment variables (request-scoped secrets — see Request-Scoped Secrets below); `LocalSandbox` merges it via `subprocess.run(env=...)` and `AioSandbox` routes env-bearing commands through the `bash.exec(env=...)` API on a fresh session.
@ -811,6 +821,8 @@ Checkpointer storage runs in one of two channel modes, selected by `checkpoint_c
**Never bypass `CheckpointStateAccessor` (`runtime/checkpoint_state.py`) for thread-state access.** It is the single choke point binding graph + checkpointer + mode: it injects the mode marker into configs, runs the compatibility check before every `get`/`update`/`history`, and returns materialized state (delta checkpoints lack `channel_values.messages` — raw `get_tuple` reads see a sentinel). Gateway `services.py` builds and passes the accessor; thread-owned reads (state/history/regeneration) must use `build_thread_checkpoint_state_accessor` so the recorded assistant's middleware schema materializes every channel. `history(limit)` semantics: `0` means zero items (explicit empty), `None` means unlimited — do not pass `limit=0` through to `graph.get_state_history`. Assistant metadata lookup is fail-closed for mutation accessors so a store outage cannot silently select the default schema and discard extension channels. In `full` mode the read path degrades to a raw checkpointer read (`_RawCheckpointReadAccessor`) when the agent factory cannot build the graph (bad model config, MCP outage) — full checkpoints carry complete `channel_values`, so reads don't need the graph; degraded snapshots take `created_at` from the standard checkpoint `ts` field, falling back to metadata only for compatibility. The delta gate still applies on the degraded path; `next`/`tasks` degrade to empty and thread status falls back to the stored status because task presence is not derivable, while delta mode has no fallback (materialization needs the channel table).
**Replay checkpoint lookup prefers lineage and degrades only for an explicitly missing legacy parent link.** Branch and regenerate paths first walk `parent_config`, which prevents a global chronological scan from selecting a sibling created by regeneration. `CheckpointParentMissingError` alone enables the bounded newest-first history fallback in `app/gateway/checkpoint_lineage.py`; cycles, dangling/non-addressable parents, target mismatches, and depth exhaustion raise `CheckpointLineageIntegrityError` and fail closed instead of selecting a sibling. The compatibility scans request 400 raw checkpoints so up to 200 duration-only entries do not consume the effective branch-history budget; the fallback scans oldest-to-newest internally, skips duration-only checkpoints, and accepts only checkpoints with an addressable id as the replay base. A source history with no discoverable pre-user checkpoint preserves the historical single-checkpoint branch behavior instead of rejecting the branch; regeneration remains unavailable for that inherited response. Existing single-checkpoint branches are not mutated by regenerate preparation, and no raw checkpoint tuple is copied across threads because delta state depends on ancestry and pending writes. Regenerate source-run lookup uses the current thread's exact event, then the server-stamped `run_id` on the copied human message, then verified RunManager content matching; it does not read parent-thread events. Storage or checkpoint-mode failures are not treated as a missing base and still fail closed.
**Wholesale message replacement uses a state-only mutation graph + `Overwrite`.** `update_state` values pass through channel reducers (`add_messages` merge in full, append in delta), so replacing `messages` wholesale (run rollback, context compaction) requires `{"messages": Overwrite([...])}`. The write goes through `build_state_mutation_graph(as_node, mode, state_schema)` — when the write carries materialized state, `state_schema` MUST be the thread's effective schema (`graph_state_schema(assistant_graph)`), because the base-ThreadState fallback silently discards written channels contributed by custom `AgentMiddleware.state_schema`. Channels absent from the write are unaffected: forked checkpoints clone the parent's channel blobs, so middleware channels survive rollback/compaction regardless of schema (locked by `test_rollback_preserves_middleware_contributed_channels` and `test_compact_thread_context_preserves_middleware_contributed_channels`) — a compiled graph with one no-op node (entry = finish) whose checkpoint machinery (channels/versions/metadata) is identical to the agent graph's but schedules no pending tasks, so the restored/compacted head stays idle instead of re-triggering the agent. Never hand-write checkpoints via `checkpointer.aput` for this; raw writers elsewhere must preserve checkpoint parentage — severed ancestry breaks delta replay (see `runtime/runs/worker.py` writer parenting and `checkpoint_patches.py`).
**Run rollback flow** (`runtime/runs/worker.py`): `_capture_rollback_point` materializes pre-run state (messages via accessor, raw `pending_writes` via `aget_tuple`) into an immutable `RollbackPoint` before the run starts — capture failure disables rollback (fail-closed), never restores partial state. Cancel-with-rollback then forks from the pre-run checkpoint via the mutation graph and replays the captured pending writes.

View File

@ -0,0 +1,164 @@
"""Shared helpers for resolving replay checkpoints on one checkpoint lineage."""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
class CheckpointLineageError(RuntimeError):
"""Raised when a requested checkpoint ancestor cannot be resolved safely."""
class CheckpointParentMissingError(CheckpointLineageError):
"""Raised when a legacy checkpoint does not record its parent link."""
class CheckpointLineageIntegrityError(CheckpointLineageError):
"""Raised when recorded checkpoint lineage is present but unsafe to use."""
def checkpoint_messages(checkpoint_tuple: Any) -> list[Any]:
values = getattr(checkpoint_tuple, "values", None)
if isinstance(values, dict):
messages = values.get("messages", [])
return list(messages) if isinstance(messages, list) else []
checkpoint = getattr(checkpoint_tuple, "checkpoint", None) or {}
channel_values = checkpoint.get("channel_values", {}) if isinstance(checkpoint, dict) else {}
messages = channel_values.get("messages", []) if isinstance(channel_values, dict) else []
return list(messages) if isinstance(messages, list) else []
def checkpoint_configurable(checkpoint_tuple: Any) -> dict[str, Any]:
config = getattr(checkpoint_tuple, "config", None) or {}
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
return dict(configurable) if isinstance(configurable, dict) else {}
def checkpoint_metadata(checkpoint_tuple: Any) -> dict[str, Any]:
metadata = getattr(checkpoint_tuple, "metadata", None) or {}
return dict(metadata) if isinstance(metadata, dict) else {}
def is_duration_only_checkpoint(checkpoint_tuple: Any) -> bool:
writes = checkpoint_metadata(checkpoint_tuple).get("writes")
return isinstance(writes, dict) and "runtime_run_duration" in writes
def _message_id(message: Any) -> str | None:
value = getattr(message, "id", None)
if value is None and isinstance(message, dict):
value = message.get("id")
return str(value) if value else None
def _config_identity(config: dict[str, Any]) -> tuple[str, str, str] | None:
configurable = config.get("configurable", {})
thread_id = configurable.get("thread_id")
checkpoint_ns = configurable.get("checkpoint_ns", "")
checkpoint_id = configurable.get("checkpoint_id")
if not isinstance(thread_id, str) or not thread_id or not isinstance(checkpoint_id, str) or not checkpoint_id:
return None
return thread_id, str(checkpoint_ns or ""), checkpoint_id
def _checkpoint_identity(checkpoint_tuple: Any) -> tuple[str, str, str] | None:
return _config_identity(getattr(checkpoint_tuple, "config", {}) or {})
def _checkpoint_exists(checkpoint_tuple: Any) -> bool:
"""Distinguish a persisted empty checkpoint from an accessor miss.
LangGraph represents a missing explicit ``checkpoint_id`` as an empty
snapshot that echoes the requested config. Persisted snapshots always
carry metadata, a creation timestamp, or a raw checkpoint payload.
"""
explicit = getattr(checkpoint_tuple, "checkpoint_exists", None)
if isinstance(explicit, bool):
return explicit
if getattr(checkpoint_tuple, "metadata", None) is not None:
return True
if getattr(checkpoint_tuple, "created_at", None) is not None:
return True
return isinstance(getattr(checkpoint_tuple, "checkpoint", None), dict)
async def find_checkpoint_before_message(
accessor: Any,
head_checkpoint: Any,
message_id: str,
*,
max_depth: int,
) -> Any:
"""Walk one parent lineage and return the first checkpoint before ``message_id``.
Following ``parent_config`` is important after a regenerate: a thread can contain
sibling checkpoint branches, and a global time-ordered scan can otherwise select
a checkpoint from the wrong branch. Duration-only metadata checkpoints do not
represent an addressable conversation state and are skipped.
"""
if message_id not in {_message_id(message) for message in checkpoint_messages(head_checkpoint)}:
raise CheckpointLineageIntegrityError("Target message is not present in the checkpoint head")
current = head_checkpoint
visited: set[tuple[str, str, str]] = set()
current_identity = _checkpoint_identity(current)
if current_identity is not None:
visited.add(current_identity)
# Each step performs one ancestor read, but normal branch/regenerate
# histories cross the target boundary within 13 reads. Keep max_depth as
# a conservative safety cap for valid histories with many intermediate or
# duration-only checkpoints.
for _ in range(max_depth):
parent_config = getattr(current, "parent_config", None)
if not isinstance(parent_config, dict):
raise CheckpointParentMissingError("Checkpoint lineage ended before the target message")
parent = await accessor.aget(parent_config)
parent_identity = _checkpoint_identity(parent)
requested_parent_identity = _config_identity(parent_config)
if parent_identity is None or not _checkpoint_exists(parent) or (requested_parent_identity is not None and parent_identity != requested_parent_identity):
raise CheckpointLineageIntegrityError("Checkpoint parent link is not addressable")
if parent_identity is not None:
if parent_identity in visited:
raise CheckpointLineageIntegrityError("Checkpoint lineage contains a cycle")
visited.add(parent_identity)
if is_duration_only_checkpoint(parent):
current = parent
continue
parent_message_ids = {_message_id(message) for message in checkpoint_messages(parent)}
if message_id not in parent_message_ids:
return parent
current = parent
raise CheckpointLineageIntegrityError(f"Checkpoint lineage exceeded the scan limit ({max_depth})")
def find_checkpoint_before_message_chronologically(
checkpoints: Sequence[Any],
message_id: str,
) -> tuple[Any | None, bool]:
"""Return ``(replay_base, target_found)`` from newest-first history.
This is a compatibility fallback for imported or legacy checkpoints that do
not carry ``parent_config`` links. Callers must prefer the lineage walk when
links are available because a chronological scan cannot distinguish sibling
checkpoint branches. Duration-only checkpoints are ignored, and only
checkpoints with an addressable id can become the replay base.
"""
previous_checkpoint = None
for checkpoint_tuple in reversed(checkpoints):
if is_duration_only_checkpoint(checkpoint_tuple):
continue
message_ids = {_message_id(message) for message in checkpoint_messages(checkpoint_tuple)}
if message_id in message_ids:
return previous_checkpoint, True
if checkpoint_configurable(checkpoint_tuple).get("checkpoint_id"):
previous_checkpoint = checkpoint_tuple
return None, False

View File

@ -23,9 +23,19 @@ from langchain_core.messages import BaseMessage
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.checkpoint_lineage import (
CheckpointLineageError,
CheckpointParentMissingError,
checkpoint_configurable,
checkpoint_messages,
find_checkpoint_before_message,
find_checkpoint_before_message_chronologically,
is_duration_only_checkpoint,
)
from app.gateway.deps import get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
from app.gateway.pagination import trim_run_message_page
from app.gateway.services import build_checkpoint_state_accessor, build_thread_checkpoint_state_accessor, sse_consumer, start_run, wait_for_run_completion
from app.gateway.utils import sanitize_log_param
from deerflow.runtime import CancelOutcome, RunRecord, RunStatus, serialize_channel_values_for_api
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, get_original_user_content_text, message_to_text
from deerflow.workspace_changes import get_workspace_changes_response
@ -37,12 +47,12 @@ REGENERATE_HISTORY_SCAN_LIMIT = 200
# (one per successful run in steady state) consume roughly half of history.
REGENERATE_HISTORY_RAW_SCAN_LIMIT = REGENERATE_HISTORY_SCAN_LIMIT * 2
THREAD_MESSAGE_PAGE_SCAN_BATCH = 201
_MISSING_REGENERATE_BASE_DETAIL = "Could not find an addressable checkpoint before the target user message"
_UNSAFE_REGENERATE_LINEAGE_DETAIL = "Could not safely resolve the checkpoint before the target user message"
def _is_duration_only_checkpoint(checkpoint_tuple: Any) -> bool:
metadata = getattr(checkpoint_tuple, "metadata", None)
writes = metadata.get("writes") if isinstance(metadata, dict) else None
return isinstance(writes, dict) and "runtime_run_duration" in writes
return is_duration_only_checkpoint(checkpoint_tuple)
def compute_run_durations(runs) -> dict[str, int]:
@ -294,15 +304,11 @@ def _is_middleware_message_row(row: dict[str, Any]) -> bool:
def _checkpoint_messages(snapshot: Any) -> list[Any]:
values = getattr(snapshot, "values", None) or {}
messages = values.get("messages", []) if isinstance(values, dict) else []
return messages if isinstance(messages, list) else []
return checkpoint_messages(snapshot)
def _checkpoint_configurable(checkpoint_tuple: Any) -> dict[str, Any]:
config = getattr(checkpoint_tuple, "config", None) or {}
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
return dict(configurable) if isinstance(configurable, dict) else {}
return checkpoint_configurable(checkpoint_tuple)
def _checkpoint_response(checkpoint_tuple: Any) -> dict[str, Any]:
@ -356,7 +362,13 @@ def _run_last_ai_matches_message(record: RunRecord, message: Any) -> bool:
return last_ai_message == target_text[: len(last_ai_message)]
async def _find_target_run_id(thread_id: str, message_id: str, target_message: Any, request: Request) -> str:
async def _find_target_run_id(
thread_id: str,
message_id: str,
target_message: Any,
source_human: Any,
request: Request,
) -> str:
event_store = get_run_event_store(request)
rows = await event_store.list_messages(thread_id, limit=REGENERATE_HISTORY_SCAN_LIMIT)
for row in reversed(rows):
@ -366,6 +378,11 @@ async def _find_target_run_id(thread_id: str, message_id: str, target_message: A
run_id = row.get("run_id")
if isinstance(run_id, str) and run_id:
return run_id
source_run_id = _message_additional_kwargs(source_human).get("run_id")
if isinstance(source_run_id, str) and source_run_id:
return source_run_id
run_mgr = get_run_manager(request)
user_id = await get_current_user(request)
records = await run_mgr.list_by_thread(thread_id, user_id=user_id, limit=10)
@ -385,8 +402,37 @@ async def _find_target_run_id(thread_id: str, message_id: str, target_message: A
raise HTTPException(status_code=409, detail="Could not find source run for assistant message")
async def _find_base_checkpoint_before_human(thread_id: str, human_message_id: str, request: Request) -> Any:
async def _find_base_checkpoint_before_human(
thread_id: str,
human_message_id: str,
request: Request,
*,
head_checkpoint: Any | None = None,
) -> Any:
accessor, base_config = await build_thread_checkpoint_state_accessor(request, thread_id=thread_id)
if head_checkpoint is not None:
try:
return await find_checkpoint_before_message(
accessor,
head_checkpoint,
human_message_id,
max_depth=REGENERATE_HISTORY_RAW_SCAN_LIMIT,
)
except CheckpointParentMissingError:
# Old checkpoints and imported histories may not have parent links.
# Preserve the bounded chronological fallback for those records.
logger.debug(
"Could not resolve parent lineage for regenerate thread %s; falling back to history scan",
sanitize_log_param(thread_id),
exc_info=True,
)
except CheckpointLineageError as exc:
logger.warning(
"Rejected unsafe checkpoint lineage for regenerate thread %s",
sanitize_log_param(thread_id),
exc_info=True,
)
raise HTTPException(status_code=409, detail=_UNSAFE_REGENERATE_LINEAGE_DETAIL) from exc
try:
raw_checkpoints = await accessor.ahistory(base_config, limit=REGENERATE_HISTORY_RAW_SCAN_LIMIT)
checkpoints = [item for item in raw_checkpoints if not _is_duration_only_checkpoint(item)]
@ -394,19 +440,14 @@ async def _find_base_checkpoint_before_human(thread_id: str, human_message_id: s
logger.exception("Failed to list checkpoints for regenerate thread %s", thread_id)
raise HTTPException(status_code=500, detail="Failed to inspect checkpoint history") from exc
previous_checkpoint = None
for checkpoint_tuple in reversed(checkpoints):
messages = _checkpoint_messages(checkpoint_tuple)
message_ids = {_message_id(message) for message in messages}
if human_message_id in message_ids:
if previous_checkpoint is None:
raise HTTPException(
status_code=409,
detail="Could not find an addressable checkpoint before the target user message",
)
return previous_checkpoint
if _checkpoint_configurable(checkpoint_tuple).get("checkpoint_id"):
previous_checkpoint = checkpoint_tuple
previous_checkpoint, target_found = find_checkpoint_before_message_chronologically(raw_checkpoints, human_message_id)
if target_found:
if previous_checkpoint is None:
raise HTTPException(
status_code=409,
detail=_MISSING_REGENERATE_BASE_DETAIL,
)
return previous_checkpoint
if len(checkpoints) >= REGENERATE_HISTORY_SCAN_LIMIT:
logger.warning(
@ -451,8 +492,19 @@ async def _prepare_regenerate_payload(thread_id: str, message_id: str, request:
if not previous_human_id:
raise HTTPException(status_code=409, detail="The source user message is missing an id")
base_checkpoint_tuple = await _find_base_checkpoint_before_human(thread_id, previous_human_id, request)
target_run_id = await _find_target_run_id(thread_id, message_id, target_message, request)
base_checkpoint_tuple = await _find_base_checkpoint_before_human(
thread_id,
previous_human_id,
request,
head_checkpoint=latest_checkpoint,
)
target_run_id = await _find_target_run_id(
thread_id,
message_id,
target_message,
previous_human,
request,
)
checkpoint = _checkpoint_response(base_checkpoint_tuple)
metadata = {
"regenerate_from_message_id": message_id,

View File

@ -25,6 +25,13 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy.exc import IntegrityError
from app.gateway.authz import require_permission
from app.gateway.checkpoint_lineage import (
CheckpointLineageError,
CheckpointParentMissingError,
find_checkpoint_before_message,
find_checkpoint_before_message_chronologically,
is_duration_only_checkpoint,
)
from app.gateway.deps import get_checkpointer, get_run_event_store, get_run_manager
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from app.gateway.services import (
@ -90,6 +97,7 @@ _SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id
_SIDECAR_METADATA_KEY = "deerflow_sidecar"
_BRANCH_METADATA_KEY = "deerflow_branch"
_BRANCH_HISTORY_SCAN_LIMIT = 200
_BRANCH_HISTORY_RAW_SCAN_LIMIT = _BRANCH_HISTORY_SCAN_LIMIT * 2
def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
@ -159,13 +167,26 @@ def _matches_branch_target(messages: list[Any], target_message_ids: set[str]) ->
return not any(_is_branch_visible_message(message) for message in messages[target_end_index + 1 :])
def _branch_target_human_message(messages: list[Any], target_message_ids: set[str]) -> Any | None:
index_by_id = {_message_id(message): index for index, message in enumerate(messages) if _message_id(message)}
if not target_message_ids.issubset(index_by_id.keys()):
return None
target_start_index = min(index_by_id[message_id] for message_id in target_message_ids)
return next(
(message for message in reversed(messages[:target_start_index]) if _message_type(message) == "human" and _is_branch_visible_message(message)),
None,
)
async def _find_branch_checkpoint(
accessor: Any,
config: dict[str, Any],
target_message_ids: set[str],
) -> Any:
try:
for snapshot in await accessor.ahistory(config, limit=_BRANCH_HISTORY_SCAN_LIMIT):
for snapshot in await accessor.ahistory(config, limit=_BRANCH_HISTORY_RAW_SCAN_LIMIT):
if is_duration_only_checkpoint(snapshot):
continue
if _matches_branch_target(_checkpoint_messages(snapshot), target_message_ids):
return snapshot
except _CHECKPOINT_MODE_ERRORS as exc:
@ -184,7 +205,9 @@ async def _branch_targets_latest_turn(
) -> bool:
"""Return whether the target turn is the final visible turn."""
try:
for snapshot in await accessor.ahistory(config, limit=_BRANCH_HISTORY_SCAN_LIMIT):
for snapshot in await accessor.ahistory(config, limit=_BRANCH_HISTORY_RAW_SCAN_LIMIT):
if is_duration_only_checkpoint(snapshot):
continue
messages = _checkpoint_messages(snapshot)
if not messages:
continue
@ -199,6 +222,57 @@ async def _branch_targets_latest_turn(
return False
async def _find_branch_replay_base(
accessor: Any,
config: dict[str, Any],
snapshot: Any,
target_human_id: str,
) -> Any | None:
"""Resolve a replay base while preserving unlinked legacy histories."""
try:
return await find_checkpoint_before_message(
accessor,
snapshot,
target_human_id,
max_depth=_BRANCH_HISTORY_RAW_SCAN_LIMIT,
)
except CheckpointParentMissingError:
thread_id = config.get("configurable", {}).get("thread_id", "")
logger.debug(
"Could not resolve parent lineage for branch thread %s; falling back to history scan",
sanitize_log_param(thread_id),
exc_info=True,
)
except CheckpointLineageError as exc:
thread_id = config.get("configurable", {}).get("thread_id", "")
logger.warning(
"Rejected unsafe checkpoint lineage for branch thread %s",
sanitize_log_param(thread_id),
exc_info=True,
)
raise HTTPException(status_code=409, detail="This turn can no longer be branched from.") from exc
try:
history = await accessor.ahistory(config, limit=_BRANCH_HISTORY_RAW_SCAN_LIMIT)
except _CHECKPOINT_MODE_ERRORS as exc:
thread_id = config.get("configurable", {}).get("thread_id", "")
raise _checkpoint_mode_http_error(exc, thread_id) from exc
except Exception as exc:
thread_id = config.get("configurable", {}).get("thread_id", "")
logger.exception("Failed to scan replay checkpoint history for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to inspect checkpoint history") from exc
replay_base, target_found = find_checkpoint_before_message_chronologically(history, target_human_id)
if not target_found:
logger.warning(
"Could not locate branch user message %s in chronological history for thread %s",
sanitize_log_param(target_human_id),
sanitize_log_param(config.get("configurable", {}).get("thread_id", "")),
)
return replay_base
def _ignore_branch_user_data(directory: str, names: list[str]) -> set[str]:
ignored: set[str] = set()
base = Path(directory)
@ -700,6 +774,16 @@ async def branch_thread(thread_id: str, body: ThreadBranchRequest, request: Requ
parent_checkpoint_id = _checkpoint_id(snapshot)
if not parent_checkpoint_id:
raise HTTPException(status_code=409, detail="This turn can no longer be branched from.")
target_human = _branch_target_human_message(_checkpoint_messages(snapshot), target_message_ids)
target_human_id = _message_id(target_human)
if not target_human_id:
raise HTTPException(status_code=409, detail="This turn can no longer be branched from.")
replay_base_tuple = await _find_branch_replay_base(
source_accessor,
source_config,
snapshot,
target_human_id,
)
# Workspace files are not checkpointed, so they only reflect the *current* thread
# state. Cloning them onto a branch from an older turn would leak files created
@ -740,20 +824,39 @@ async def branch_thread(thread_id: str, body: ThreadBranchRequest, request: Requ
branch_reducer_fields = graph_reducer_channels(getattr(branch_accessor, "graph", None))
if branch_reducer_fields is None:
branch_reducer_fields = THREAD_STATE_REDUCER_FIELDS
branch_values = {}
for key, value in dict(snapshot.values).items():
if key in branch_reducer_fields:
branch_values[key] = Overwrite(list(value) if key == "messages" and isinstance(value, list) else value)
else:
branch_values[key] = value
new_config.setdefault("metadata", {}).update(
{
**branch_metadata,
"source": "branch",
}
)
def branch_values(source_snapshot: Any) -> dict[str, Any]:
values: dict[str, Any] = {}
for key, value in dict(source_snapshot.values).items():
if key in branch_reducer_fields:
values[key] = Overwrite(list(value) if key == "messages" and isinstance(value, list) else value)
else:
values[key] = value
return values
# Stamp both synthetic checkpoints with the branch-creation time because
# serializers fall back to metadata when snapshot.created_at is absent.
checkpoint_metadata_updates = {
**branch_metadata,
"source": "branch",
"updated_at": now,
"created_at": now,
}
new_config.setdefault("metadata", {}).update(checkpoint_metadata_updates)
try:
await branch_accessor.aupdate(new_config, branch_values, as_node="branch")
head_config = new_config
if replay_base_tuple is not None:
head_config = await branch_accessor.aupdate(
new_config,
branch_values(replay_base_tuple),
as_node="branch",
)
head_config.setdefault("metadata", {}).update(checkpoint_metadata_updates)
await branch_accessor.aupdate(
head_config,
branch_values(snapshot),
as_node="branch",
)
except _CHECKPOINT_MODE_ERRORS as exc:
raise _checkpoint_mode_http_error(exc, new_thread_id) from exc
except Exception:

View File

@ -629,9 +629,10 @@ class _RawCheckpointSnapshot:
metadata, config ancestry, created_at) comes straight from the tuple.
"""
__slots__ = ("config", "values", "metadata", "parent_config", "created_at", "tasks", "tasks_known", "next")
__slots__ = ("checkpoint_exists", "config", "values", "metadata", "parent_config", "created_at", "tasks", "tasks_known", "next")
def __init__(self, config: dict[str, Any], tup: Any | None) -> None:
self.checkpoint_exists = tup is not None
self.config = getattr(tup, "config", None) or config
checkpoint = getattr(tup, "checkpoint", None) or {}
self.values = dict(checkpoint.get("channel_values") or {})

View File

@ -1,12 +1,15 @@
from __future__ import annotations
import asyncio
import copy
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.base import empty_checkpoint, uuid6
from langgraph.checkpoint.memory import InMemorySaver
from deerflow.runtime import RunStatus
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
@ -27,6 +30,38 @@ def _checkpoint(checkpoint_id: str, messages: list[object], *, metadata: dict |
)
async def _put_memory_checkpoint(
checkpointer: InMemorySaver,
thread_id: str,
messages: list[object],
*,
step: int,
parent_config: dict | None = None,
metadata: dict | None = None,
) -> dict:
checkpoint = empty_checkpoint()
checkpoint["id"] = str(uuid6())
checkpoint["channel_values"] = {"messages": messages}
checkpoint["channel_versions"] = {"messages": step}
checkpoint_metadata = {
"step": step,
"source": "loop",
"writes": {"test": {"messages": messages}},
"parents": {},
}
checkpoint_metadata.update(metadata or {})
return await checkpointer.aput(
parent_config or {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
checkpoint,
checkpoint_metadata,
{"messages": step},
)
async def _collect_checkpoints(checkpointer: InMemorySaver, config: dict) -> list:
return [checkpoint async for checkpoint in checkpointer.alist(config)]
class FakeCheckpointer:
def __init__(self, history, *, latest=None, materialized_history=None, materialized_latest=None):
self.history = history
@ -72,19 +107,26 @@ class FakeAccessor:
values=dict(checkpoint.checkpoint.get("channel_values", {})),
config=checkpoint.config,
metadata=checkpoint.metadata,
parent_config=getattr(checkpoint, "parent_config", None),
)
async def aget(self, _config):
if self.checkpointer.materialized_latest is not None:
return self.checkpointer.materialized_latest
raw = self.checkpointer.latest or (self.checkpointer.history[0] if self.checkpointer.history else None)
async def aget(self, config):
materialized_latest = getattr(self.checkpointer, "materialized_latest", None)
if materialized_latest is not None and not config.get("configurable", {}).get("checkpoint_id"):
return materialized_latest
raw = await self.checkpointer.aget_tuple(config)
return self._from_raw(raw) if raw is not None else SimpleNamespace(values={}, config={}, metadata={})
async def ahistory(self, _config, *, limit=None):
self.checkpointer.alist_limits.append(limit)
history = self.checkpointer.materialized_history
async def ahistory(self, config, *, limit=None):
alist_limits = getattr(self.checkpointer, "alist_limits", None)
if alist_limits is not None:
alist_limits.append(limit)
history = getattr(self.checkpointer, "materialized_history", None)
if history is None:
history = [self._from_raw(item) for item in self.checkpointer.history]
if hasattr(self.checkpointer, "history"):
history = [self._from_raw(item) for item in self.checkpointer.history]
else:
history = [self._from_raw(item) async for item in self.checkpointer.alist(config, limit=limit)]
return history[:limit]
@ -354,6 +396,110 @@ def test_prepare_regenerate_payload_returns_clean_input_and_base_checkpoint():
assert regenerated_human["additional_kwargs"] == {"files": [{"filename": "data.csv", "path": "/mnt/user-data/uploads/data.csv"}]}
def test_prepare_regenerate_payload_does_not_mutate_legacy_single_checkpoint_branch():
from app.gateway.routers.thread_runs import _prepare_regenerate_payload
checkpointer = InMemorySaver()
source_thread_id = "source-thread"
branch_thread_id = "legacy-branch"
source_run_id = "source-run"
human = HumanMessage(id="human-1", content="question", additional_kwargs={"run_id": source_run_id})
ai = AIMessage(id="ai-1", content="answer")
async def _seed() -> str:
source_base_config = await _put_memory_checkpoint(checkpointer, source_thread_id, [], step=0)
after_human = await _put_memory_checkpoint(
checkpointer,
source_thread_id,
[human],
step=1,
parent_config=source_base_config,
)
source_head_config = await _put_memory_checkpoint(
checkpointer,
source_thread_id,
[human, ai],
step=2,
parent_config=after_human,
)
source_head = await checkpointer.aget_tuple(source_head_config)
assert source_head is not None
legacy_head = copy.deepcopy(source_head.checkpoint)
legacy_head_id = str(uuid6())
legacy_head["id"] = legacy_head_id
legacy_metadata = copy.deepcopy(source_head.metadata)
legacy_metadata.update(
{
"source": "branch",
"deerflow_branch": True,
"branch_parent_thread_id": source_thread_id,
"branch_parent_checkpoint_id": source_head_config["configurable"]["checkpoint_id"],
"branch_parent_message_id": "ai-1",
}
)
await checkpointer.aput(
{"configurable": {"thread_id": branch_thread_id, "checkpoint_ns": ""}},
legacy_head,
legacy_metadata,
dict(legacy_head["channel_versions"]),
)
return legacy_head_id
legacy_head_id = asyncio.run(_seed())
request = _request(checkpointer, FakeEventStore([]))
with pytest.raises(HTTPException) as exc:
asyncio.run(_prepare_regenerate_payload(branch_thread_id, "ai-1", request))
assert exc.value.status_code == 409
assert exc.value.detail == "Could not find an addressable checkpoint before the target user message"
latest = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": branch_thread_id, "checkpoint_ns": ""}}))
assert latest is not None
assert latest.config["configurable"]["checkpoint_id"] == legacy_head_id
branch_history = asyncio.run(_collect_checkpoints(checkpointer, {"configurable": {"thread_id": branch_thread_id, "checkpoint_ns": ""}}))
assert [item.config["configurable"]["checkpoint_id"] for item in branch_history] == [legacy_head_id]
def test_prepare_regenerate_payload_rejects_legacy_branch_when_source_checkpoint_is_missing():
from app.gateway.routers.thread_runs import _prepare_regenerate_payload
checkpointer = InMemorySaver()
branch_thread_id = "legacy-orphan"
human = HumanMessage(id="human-1", content="question", additional_kwargs={"run_id": "source-run"})
ai = AIMessage(id="ai-1", content="answer")
async def _seed() -> None:
await _put_memory_checkpoint(
checkpointer,
branch_thread_id,
[human, ai],
step=1,
metadata={
"source": "branch",
"deerflow_branch": True,
"branch_parent_thread_id": "deleted-source",
"branch_parent_checkpoint_id": "missing-checkpoint",
"branch_parent_message_id": "ai-1",
},
)
asyncio.run(_seed())
with pytest.raises(HTTPException) as exc:
asyncio.run(
_prepare_regenerate_payload(
branch_thread_id,
"ai-1",
_request(checkpointer, FakeEventStore([])),
)
)
assert exc.value.status_code == 409
assert exc.value.detail == "Could not find an addressable checkpoint before the target user message"
branch_history = asyncio.run(_collect_checkpoints(checkpointer, {"configurable": {"thread_id": branch_thread_id, "checkpoint_ns": ""}}))
assert len(branch_history) == 1
def test_prepare_regenerate_uses_materialized_history_when_raw_messages_are_omitted():
from app.gateway.routers.thread_runs import _prepare_regenerate_payload
@ -402,6 +548,124 @@ def test_prepare_regenerate_uses_materialized_history_when_raw_messages_are_omit
assert checkpointer.alist_limits == [400]
def test_prepare_regenerate_rejects_cyclic_lineage_without_chronological_fallback():
from app.gateway.routers import thread_runs
human = HumanMessage(id="human-1", content="question")
ai = AIMessage(id="ai-1", content="answer")
def linked_snapshot(checkpoint_id: str, messages: list[object], parent_id: str) -> SimpleNamespace:
return SimpleNamespace(
values={"messages": messages},
config={
"configurable": {
"thread_id": "thread-1",
"checkpoint_ns": "",
"checkpoint_id": checkpoint_id,
}
},
metadata={},
parent_config={
"configurable": {
"thread_id": "thread-1",
"checkpoint_ns": "",
"checkpoint_id": parent_id,
}
},
)
head = linked_snapshot("head", [human, ai], "cycle")
cycle = linked_snapshot("cycle", [human], "head")
wrong_sibling_base = linked_snapshot("wrong-sibling", [], "root")
by_id = {"head": head, "cycle": cycle}
async def aget(config):
return by_id[config["configurable"]["checkpoint_id"]]
accessor = SimpleNamespace(
aget=aget,
ahistory=AsyncMock(return_value=[head, wrong_sibling_base]),
)
builder = AsyncMock(
return_value=(
accessor,
{"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}},
)
)
with patch.object(thread_runs, "build_thread_checkpoint_state_accessor", builder):
with pytest.raises(HTTPException) as exc:
asyncio.run(
thread_runs._find_base_checkpoint_before_human(
"thread-1",
"human-1",
_request(FakeCheckpointer([]), FakeEventStore([])),
head_checkpoint=head,
)
)
assert exc.value.status_code == 409
assert exc.value.detail == "Could not safely resolve the checkpoint before the target user message"
accessor.ahistory.assert_not_awaited()
def test_prepare_regenerate_rejects_dangling_parent_without_chronological_fallback():
from app.gateway.routers import thread_runs
human = HumanMessage(id="human-1", content="question")
ai = AIMessage(id="ai-1", content="answer")
head = SimpleNamespace(
values={"messages": [human, ai]},
config={
"configurable": {
"thread_id": "thread-1",
"checkpoint_ns": "",
"checkpoint_id": "head",
}
},
metadata={},
parent_config={
"configurable": {
"thread_id": "thread-1",
"checkpoint_ns": "",
"checkpoint_id": "missing",
}
},
)
missing = SimpleNamespace(
values={},
config=head.parent_config,
metadata=None,
created_at=None,
parent_config=None,
)
accessor = SimpleNamespace(
aget=AsyncMock(return_value=missing),
ahistory=AsyncMock(return_value=[head, _snapshot("wrong-sibling", [])]),
)
builder = AsyncMock(
return_value=(
accessor,
{"configurable": {"thread_id": "thread-1", "checkpoint_ns": ""}},
)
)
with patch.object(thread_runs, "build_thread_checkpoint_state_accessor", builder):
with pytest.raises(HTTPException) as exc:
asyncio.run(
thread_runs._find_base_checkpoint_before_human(
"thread-1",
"human-1",
_request(FakeCheckpointer([]), FakeEventStore([])),
head_checkpoint=head,
)
)
assert exc.value.status_code == 409
assert exc.value.detail == "Could not safely resolve the checkpoint before the target user message"
accessor.ahistory.assert_not_awaited()
def test_prepare_regenerate_payload_rejects_non_latest_assistant():
from app.gateway.routers.thread_runs import _prepare_regenerate_payload
@ -459,6 +723,30 @@ def test_prepare_regenerate_payload_falls_back_to_matching_run_when_events_are_m
assert response.metadata["regenerate_from_run_id"] == "run-latest"
def test_prepare_regenerate_payload_uses_server_stamped_human_run_id_without_parent_events():
from app.gateway.routers.thread_runs import _prepare_regenerate_payload
human = HumanMessage(id="human-1", content="question", additional_kwargs={"run_id": "parent-run"})
ai = AIMessage(id="ai-1", content="answer")
base = _checkpoint("ckpt-base", [])
after_human = _checkpoint("ckpt-human", [human])
latest = _checkpoint(
"ckpt-ai",
[human, ai],
metadata={
"deerflow_branch": True,
"branch_parent_thread_id": "parent-thread",
"branch_parent_checkpoint_id": "parent-checkpoint",
},
)
checkpointer = FakeCheckpointer([latest, after_human, base])
event_store = FakeEventStore([])
response = asyncio.run(_prepare_regenerate_payload("thread-1", "ai-1", _request(checkpointer, event_store)))
assert response.target_run_id == "parent-run"
def test_prepare_regenerate_payload_rejects_unverified_run_fallback_when_events_are_missing():
from app.gateway.routers.thread_runs import _prepare_regenerate_payload

View File

@ -14,7 +14,7 @@ from langgraph.store.memory import InMemoryStore
from langgraph.types import Overwrite
from app.gateway import services as gateway_services
from app.gateway.routers import threads
from app.gateway.routers import thread_runs, threads
from deerflow.config.paths import Paths
from deerflow.persistence.thread_meta import InvalidMetadataFilterError
from deerflow.persistence.thread_meta.memory import THREADS_NS, MemoryThreadMetaStore
@ -165,6 +165,7 @@ def _patch_checkpoint_state_builder(monkeypatch):
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:
@ -209,6 +210,7 @@ async def _write_checkpoint(
*,
step: int,
metadata: dict | None = None,
parent_config: dict | None = None,
) -> dict:
checkpoint = empty_checkpoint()
checkpoint["id"] = checkpoint_id
@ -223,7 +225,7 @@ async def _write_checkpoint(
}
checkpoint_metadata.update(metadata or {})
return await checkpointer.aput(
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
parent_config or {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
checkpoint,
checkpoint_metadata,
{"messages": step},
@ -1100,6 +1102,86 @@ def test_ai_message_lacks_duration_only_for_unannotated_ai_messages() -> None:
# ── 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 = SimpleNamespace(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"
@ -1111,16 +1193,49 @@ def test_branch_thread_from_older_assistant_turn_creates_truncated_thread() -> N
human_3 = HumanMessage(id="human-3", content="Third question")
ai_3 = AIMessage(id="ai-3", content="Third answer")
async def _seed() -> None:
await _write_checkpoint(checkpointer, source_thread_id, "0001", [human_1, ai_1], step=1)
await _write_checkpoint(checkpointer, source_thread_id, "0002", [human_1, ai_1, human_2, ai_2], step=2)
await _write_checkpoint(checkpointer, source_thread_id, "0003", [human_1, ai_1, human_2, ai_2, human_3, ai_3], step=3)
asyncio.run(_seed())
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,
@ -1149,7 +1264,7 @@ def test_branch_thread_from_older_assistant_turn_creates_truncated_thread() -> N
search_response = client.post("/api/threads/search", json={"limit": 10})
assert body["parent_thread_id"] == source_thread_id
assert body["parent_checkpoint_id"] == "0002"
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"
@ -1172,7 +1287,7 @@ def test_branch_thread_uses_materialized_history_and_overwrites_fresh_seed(monke
AIMessage(id="a2", content="Second answer"),
]
def snapshot(checkpoint_id: str, materialized_messages: list[object]) -> SimpleNamespace:
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={
@ -1183,31 +1298,48 @@ def test_branch_thread_uses_materialized_history_and_overwrites_fresh_seed(monke
}
},
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),
snapshot("ckpt-1", messages[:2]),
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 == 200
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": "branch-seed",
"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):
@ -1244,13 +1376,179 @@ def test_branch_thread_uses_materialized_history_and_overwrites_fresh_seed(monke
assert response.status_code == 200, response.text
body = response.json()
assert body["parent_checkpoint_id"] == "ckpt-1"
assert len(branch_updates) == 1
update_config, update_values, as_node = branch_updates[0]
assert isinstance(update_values["messages"], Overwrite)
assert [message.id for message in update_values["messages"].value] == ["h1", "a1"]
assert update_config["configurable"]["thread_id"] == body["thread_id"]
assert update_config["metadata"]["source"] == "branch"
assert as_node == "branch"
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:
@ -1264,7 +1562,7 @@ def test_branch_thread_real_mutation_graph_finishes_without_scheduling(monkeypat
AIMessage(id="a2", content="Second answer"),
]
def snapshot(checkpoint_id: str, materialized_messages: list[object]) -> SimpleNamespace:
def snapshot(checkpoint_id: str, materialized_messages: list[object], *, parent_id: str | None = None) -> SimpleNamespace:
return SimpleNamespace(
values={"messages": materialized_messages},
config={
@ -1275,15 +1573,27 @@ def test_branch_thread_real_mutation_graph_finishes_without_scheduling(monkeypat
}
},
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=[
snapshot("ckpt-2", messages),
snapshot("ckpt-1", messages[:2]),
]
)
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):
@ -1374,6 +1684,7 @@ def _wire_extension_agent(monkeypatch, app, checkpointer, mode):
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
@ -1389,6 +1700,24 @@ async def _seed_extension_source(checkpointer, custom_factory, mode, source_thre
{"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"])
@ -1397,11 +1726,23 @@ def test_state_endpoints_preserve_extension_reducer_channels(monkeypatch, mode)
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.
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 = SimpleNamespace(list_by_thread=list_by_thread)
recorded_updates: list[dict] = []
real_mutation_builder = gateway_services.build_checkpoint_state_mutation_accessor
@ -1444,11 +1785,17 @@ def test_state_endpoints_preserve_extension_reducer_channels(monkeypatch, mode)
branch_response = client.post(
f"/api/threads/{source_thread_id}/branches",
json={"message_id": "a1", "message_ids": ["a1"]},
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)
@ -1462,7 +1809,24 @@ def test_state_endpoints_preserve_extension_reducer_channels(monkeypatch, mode)
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"]
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):
@ -1625,14 +1989,16 @@ def test_branch_thread_rejects_non_assistant_targets() -> None:
human = HumanMessage(id="human-1", content="Question")
ai = AIMessage(id="ai-1", content="Answer")
async def _seed() -> None:
await _write_checkpoint(checkpointer, source_thread_id, "0001", [human, ai], step=1)
asyncio.run(_seed())
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",
@ -1660,10 +2026,9 @@ def test_branch_thread_best_effort_copies_current_workspace(tmp_path) -> None:
human = HumanMessage(id="human-file", content="Make a file")
ai = AIMessage(id="ai-file", content="Done")
async def _seed() -> None:
await _write_checkpoint(checkpointer, source_thread_id, "0001", [human, ai], step=1)
asyncio.run(_seed())
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),
@ -1672,6 +2037,9 @@ def test_branch_thread_best_effort_copies_current_workspace(tmp_path) -> None:
):
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",
@ -1711,11 +2079,26 @@ def test_branch_thread_from_historical_turn_skips_workspace_clone(tmp_path) -> N
human_2 = HumanMessage(id="human-2", content="Second question")
ai_2 = AIMessage(id="ai-2", content="Second answer")
async def _seed() -> None:
await _write_checkpoint(checkpointer, source_thread_id, "0001", [human_1, ai_1], step=1)
await _write_checkpoint(checkpointer, source_thread_id, "0002", [human_1, ai_1, human_2, ai_2], step=2)
asyncio.run(_seed())
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),
@ -1724,6 +2107,9 @@ def test_branch_thread_from_historical_turn_skips_workspace_clone(tmp_path) -> N
):
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",
@ -1732,7 +2118,7 @@ def test_branch_thread_from_historical_turn_skips_workspace_clone(tmp_path) -> N
assert response.status_code == 200, response.text
body = response.json()
assert body["parent_checkpoint_id"] == "0001"
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)