fix: honor checkpoint object in thread state updates (#7115)

* fix: honor checkpoint object in thread state updates

Prefer the checkpoint object over the deprecated checkpoint_id, validate
its thread_id, and preserve checkpoint namespace when updating state.

Add regression coverage for checkpoint-object forks and thread mismatch.

* fix: preserve client context handling in checkpoint PR

* fix: support state updates in checkpoint namespaces

* fix: reject unknown checkpoint namespaces

* fix(gateway): isolate subgraph titles from thread metadata

Only synchronize state titles into conversation metadata for the root checkpoint namespace. Cover persisted child updates with and without checkpoint IDs in full and delta modes, and retain root title synchronization and branch sequence cleanup.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Gusanity 2026-10-24 07:17:27 +08:00 committed by GitHub
parent 75df1be6b7
commit d318a89b80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 315 additions and 41 deletions

View File

@ -2255,6 +2255,8 @@ information.
Interrupted first-turn runs still persist a fallback conversation title, so stopping a streaming response does not leave the thread as "Untitled" after refresh.
Updating a subgraph checkpoint's title through the state API keeps the root conversation's title unchanged.
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. Automatically inherited branch titles use the next free language-neutral numeric suffix (`Title (2)`, then `Title (3)` for another sibling or a branch of the numbered conversation) so generated sibling titles remain distinct without persisting a locale-specific label; matching explicit or renamed sibling titles also reserve their displayed suffix even though they carry no generated sequence metadata. An explicit API title is preserved. Renaming a branch clears its generated sequence, so its next automatic branch starts from the renamed title at `(2)`. Recent chats also groups loaded branches directly beneath a loaded parent with subtle tree connectors. Missing parents, malformed or cyclic lineage, and branches in a different pinned state stay visible at the top level instead of being hidden or moved across the pinned boundary. The new thread starts from that turn's checkpoint and keeps the preceding replay checkpoint, so the branched response can be regenerated immediately. The latest response can also be regenerated after an interruption, even when its streamed partial text never reached a checkpoint. Stopping mid-answer retains the assistant text that was already streamed in the thread's message feed, so refreshing no longer discards visible progress; that partial text remains outside authoritative completed checkpoint state. Regenerating the latest response preserves the thread's current title, including a title you renamed manually after the original response. 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.

View File

@ -66,3 +66,8 @@ external filesystem writers do not participate in this history guarantee.
Plugin actions stamp `ActionContext.interaction=session` only from authenticated
session provenance. Never accept it from payloads or propagate it to model tools;
extensions use it to distinguish owner review from model/tool output.
State updates synchronize titles into thread metadata only for the root checkpoint
namespace. Nonroot writes preserve the conversation title, branch-title sequence
and activity timestamp. `tests/test_threads_router.py` checks metadata/search
isolation and root title synchronization in full and delta modes.

View File

@ -1635,33 +1635,49 @@ async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateReques
thread_store = get_thread_store(request)
# Validate external roles before materializing a graph or reserving a write.
values = strip_server_owned_state_metadata(dict(body.values or {}))
if body.checkpoint_id is not None:
if not body.checkpoint_id:
checkpoint_id = body.checkpoint_id
checkpoint_ns = ""
if body.checkpoint is not None:
checkpoint_thread_id = body.checkpoint.get("thread_id")
if checkpoint_thread_id is not None and str(checkpoint_thread_id) != thread_id:
raise HTTPException(status_code=400, detail="checkpoint thread_id does not match request thread_id")
raw_checkpoint_id = body.checkpoint.get("checkpoint_id")
checkpoint_id = str(raw_checkpoint_id) if raw_checkpoint_id else raw_checkpoint_id
raw_checkpoint_ns = body.checkpoint.get("checkpoint_ns")
if raw_checkpoint_ns is not None:
checkpoint_ns = str(raw_checkpoint_ns)
if checkpoint_id is not None or checkpoint_ns:
if checkpoint_id is not None and not checkpoint_id:
raise HTTPException(status_code=404, detail="Checkpoint not found")
selected_config = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
"checkpoint_id": body.checkpoint_id,
"checkpoint_ns": checkpoint_ns,
}
}
if checkpoint_id is not None:
selected_config["configurable"]["checkpoint_id"] = checkpoint_id
try:
checkpoint_tuple = await get_checkpointer(request).aget_tuple(selected_config)
except Exception:
logger.exception("Failed to get state for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to get thread state")
if checkpoint_tuple is None:
raise HTTPException(status_code=404, detail=f"Checkpoint {body.checkpoint_id} not found")
detail = f"Checkpoint {checkpoint_id} not found" if checkpoint_id is not None else "Checkpoint not found"
raise HTTPException(status_code=404, detail=detail)
mutation_node = body.as_node or "manual_state_update"
# Resolve through the shared boundary (thread metadata -> assistant_id ->
# effective schema) so extension middleware channels stay writable.
accessor, read_config = await build_thread_checkpoint_state_mutation_accessor(
request,
thread_id=thread_id,
as_node=mutation_node,
checkpoint_id=body.checkpoint_id,
)
accessor_kwargs: dict[str, Any] = {
"thread_id": thread_id,
"as_node": mutation_node,
"checkpoint_id": checkpoint_id,
}
if checkpoint_ns:
accessor_kwargs["checkpoint_ns"] = checkpoint_ns
accessor, read_config = await build_thread_checkpoint_state_mutation_accessor(request, **accessor_kwargs)
writable_channels = graph_writable_channels(getattr(accessor, "graph", None))
if writable_channels is not None:
unknown_fields = sorted(set(values) - writable_channels)
@ -1699,7 +1715,7 @@ async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateReques
logger.exception("Failed to update state for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to update thread state")
if thread_store and body.values and "title" in body.values:
if not checkpoint_ns and thread_store and body.values and "title" in body.values:
new_title = body.values["title"]
if new_title:
try:

View File

@ -1393,6 +1393,7 @@ def build_checkpoint_state_mutation_accessor(
thread_id: str,
as_node: str,
checkpoint_id: str | None = None,
checkpoint_ns: str = "",
state_schema: Any | None = None,
) -> tuple[CheckpointStateAccessor, dict[str, Any]]:
"""Build a state-only graph whose writer node finishes immediately.
@ -1406,7 +1407,7 @@ def build_checkpoint_state_mutation_accessor(
config: dict[str, Any] = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
"checkpoint_ns": checkpoint_ns,
}
}
if checkpoint_id is not None:
@ -1419,6 +1420,7 @@ def build_checkpoint_state_mutation_accessor(
get_checkpointer(request),
store=getattr(request.app.state, "store", None),
mode=mode,
direct_namespace=True,
)
return accessor, config
@ -1583,12 +1585,13 @@ def build_checkpoint_state_accessor(
thread_id: str,
assistant_id: str | None = None,
checkpoint_id: str | None = None,
checkpoint_ns: str = "",
) -> tuple[CheckpointStateAccessor, dict[str, Any]]:
"""Build the mode-selected lead graph used for materialized checkpoint state."""
ctx = get_run_context(request)
config = build_run_config(thread_id, None, None, assistant_id=assistant_id)
configurable = config.setdefault("configurable", {})
configurable["checkpoint_ns"] = ""
configurable["checkpoint_ns"] = checkpoint_ns
if checkpoint_id is not None:
configurable["checkpoint_id"] = checkpoint_id
@ -1634,6 +1637,7 @@ async def abuild_checkpoint_state_accessor(
thread_id: str,
assistant_id: str | None = None,
checkpoint_id: str | None = None,
checkpoint_ns: str = "",
) -> tuple[CheckpointStateAccessor, dict[str, Any]]:
"""Async variant of :func:`build_checkpoint_state_accessor`.
@ -1647,13 +1651,14 @@ async def abuild_checkpoint_state_accessor(
identity changed while it waited rebuilds instead of reusing the
winner's graph.
"""
return await run_assembly(
build_checkpoint_state_accessor,
request,
thread_id=thread_id,
assistant_id=assistant_id,
checkpoint_id=checkpoint_id,
)
kwargs: dict[str, Any] = {
"thread_id": thread_id,
"assistant_id": assistant_id,
"checkpoint_id": checkpoint_id,
}
if checkpoint_ns:
kwargs["checkpoint_ns"] = checkpoint_ns
return await run_assembly(build_checkpoint_state_accessor, request, **kwargs)
async def resolve_thread_assistant_id(
@ -1686,6 +1691,7 @@ async def build_thread_checkpoint_state_accessor(
*,
thread_id: str,
checkpoint_id: str | None = None,
checkpoint_ns: str = "",
fail_closed: bool = False,
) -> tuple[CheckpointStateAccessor, dict[str, Any]]:
"""Single resolution boundary for state endpoints.
@ -1695,12 +1701,14 @@ async def build_thread_checkpoint_state_accessor(
``AgentMiddleware.state_schema`` from the response.
"""
assistant_id = await resolve_thread_assistant_id(request, thread_id, fail_closed=fail_closed)
return await abuild_checkpoint_state_accessor(
request,
thread_id=thread_id,
assistant_id=assistant_id,
checkpoint_id=checkpoint_id,
)
kwargs: dict[str, Any] = {
"thread_id": thread_id,
"assistant_id": assistant_id,
"checkpoint_id": checkpoint_id,
}
if checkpoint_ns:
kwargs["checkpoint_ns"] = checkpoint_ns
return await abuild_checkpoint_state_accessor(request, **kwargs)
async def build_thread_checkpoint_state_mutation_accessor(
@ -1709,6 +1717,7 @@ async def build_thread_checkpoint_state_mutation_accessor(
thread_id: str,
as_node: str,
checkpoint_id: str | None = None,
checkpoint_ns: str = "",
) -> tuple[CheckpointStateAccessor, dict[str, Any]]:
"""Mutation accessor compiled with the thread's effective state schema.
@ -1716,20 +1725,24 @@ async def build_thread_checkpoint_state_mutation_accessor(
so writes carrying materialized state do not silently discard
extension-owned channels.
"""
read_accessor, _read_config = await build_thread_checkpoint_state_accessor(
request,
thread_id=thread_id,
checkpoint_id=checkpoint_id,
fail_closed=True,
)
read_kwargs: dict[str, Any] = {
"thread_id": thread_id,
"checkpoint_id": checkpoint_id,
"fail_closed": True,
}
if checkpoint_ns:
read_kwargs["checkpoint_ns"] = checkpoint_ns
read_accessor, _read_config = await build_thread_checkpoint_state_accessor(request, **read_kwargs)
state_schema = graph_state_schema(getattr(read_accessor, "graph", None))
return build_checkpoint_state_mutation_accessor(
request,
thread_id=thread_id,
as_node=as_node,
checkpoint_id=checkpoint_id,
state_schema=state_schema,
)
mutation_kwargs: dict[str, Any] = {
"thread_id": thread_id,
"as_node": as_node,
"checkpoint_id": checkpoint_id,
"state_schema": state_schema,
}
if checkpoint_ns:
mutation_kwargs["checkpoint_ns"] = checkpoint_ns
return build_checkpoint_state_mutation_accessor(request, **mutation_kwargs)
async def apply_checkpoint_to_run_config(

View File

@ -114,6 +114,7 @@ class CheckpointStateAccessor:
graph: Any
checkpointer: Any
mode: CheckpointChannelMode
direct_namespace: bool = False
@classmethod
def bind(
@ -123,11 +124,12 @@ class CheckpointStateAccessor:
*,
store: Any | None = None,
mode: CheckpointChannelMode = "full",
direct_namespace: bool = False,
) -> CheckpointStateAccessor:
graph.checkpointer = checkpointer
if store is not None:
graph.store = store
return cls(graph=graph, checkpointer=checkpointer, mode=mode)
return cls(graph=graph, checkpointer=checkpointer, mode=mode, direct_namespace=direct_namespace)
def _prepare_config(self, config: dict[str, Any]) -> dict[str, Any]:
prepared = {
@ -135,6 +137,15 @@ class CheckpointStateAccessor:
"configurable": dict(config.get("configurable", {})),
"metadata": dict(config.get("metadata", {})),
}
if self.direct_namespace and prepared["configurable"].get("checkpoint_ns"):
# A state-only mutation graph has no subgraphs to delegate to. Bind
# the saver explicitly so LangGraph reads/writes this namespace on
# the graph itself, including when reading back an updated config.
# This private key is verified against LangGraph 1.2.9; recheck it
# when upgrading LangGraph because its internal location may move.
from langgraph._internal._constants import CONFIG_KEY_CHECKPOINTER
prepared["configurable"][CONFIG_KEY_CHECKPOINTER] = self.checkpointer
inject_checkpoint_mode(prepared, self.mode)
return prepared

View File

@ -4396,6 +4396,233 @@ def test_update_thread_state_real_mutation_graph_finishes_without_scheduling(mon
assert body["next"] == []
def test_update_thread_state_forks_from_checkpoint_object(monkeypatch) -> None:
app, _store, _checkpointer = _build_thread_app()
thread_id = "state-checkpoint-object"
real_mutation_builder = gateway_services.build_checkpoint_state_mutation_accessor
async def mutation_boundary(
request,
*,
thread_id,
as_node,
checkpoint_id=None,
checkpoint_ns="",
):
return real_mutation_builder(
request,
thread_id=thread_id,
as_node=as_node,
checkpoint_id=checkpoint_id,
checkpoint_ns=checkpoint_ns,
)
monkeypatch.setattr(
threads,
"build_thread_checkpoint_state_mutation_accessor",
mutation_boundary,
)
with TestClient(app) as client:
created = client.post(
"/api/threads",
json={"thread_id": thread_id, "metadata": {}},
)
assert created.status_code == 200, created.text
first = client.post(
f"/api/threads/{thread_id}/state",
json={"values": {"title": "First"}},
)
assert first.status_code == 200, first.text
first_checkpoint_id = first.json()["checkpoint_id"]
second = client.post(
f"/api/threads/{thread_id}/state",
json={"values": {"title": "Second"}},
)
assert second.status_code == 200, second.text
second_checkpoint_id = second.json()["checkpoint_id"]
forked = client.post(
f"/api/threads/{thread_id}/state",
json={
"checkpoint_id": second_checkpoint_id,
"checkpoint": {
"thread_id": thread_id,
"checkpoint_ns": "",
"checkpoint_id": first_checkpoint_id,
},
"values": {"title": "Forked from first"},
},
)
assert forked.status_code == 200, forked.text
assert forked.json()["parent_checkpoint_id"] == first_checkpoint_id
assert forked.json()["values"]["title"] == "Forked from first"
@pytest.mark.parametrize("mode", ["full", "delta"])
@pytest.mark.parametrize("include_checkpoint_id", [True, False])
def test_update_thread_state_in_persisted_subgraph_namespace(monkeypatch, mode, include_checkpoint_id) -> None:
from langgraph.graph import StateGraph
from deerflow.agents.thread_state import get_thread_state_schema
app, _store, checkpointer = _build_thread_app()
app.state.checkpoint_channel_mode = mode
thread_id = f"state-subgraph-{mode}"
with TestClient(app) as client:
assert client.post("/api/threads", json={"thread_id": thread_id, "metadata": {"branch_title_sequence": 2}}).status_code == 200
schema = get_thread_state_schema(mode)
child = StateGraph(schema)
child.add_node("write_child", lambda state: {"title": "Child original"})
child.set_entry_point("write_child")
child.set_finish_point("write_child")
parent = StateGraph(schema)
parent.add_node("child", child.compile())
parent.set_entry_point("child")
parent.add_node("restore_root_title", lambda state: {"title": "Root original"})
parent.add_edge("child", "restore_root_title")
parent.set_finish_point("restore_root_title")
graph = parent.compile(checkpointer=checkpointer)
asyncio.run(graph.ainvoke({"title": "Root original"}, {"configurable": {"thread_id": thread_id}}))
namespaces = [ns for ns in checkpointer.storage[thread_id] if ns]
assert len(namespaces) == 1
namespace = namespaces[0]
selected = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": namespace}}))
assert selected is not None
selected_id = selected.config["configurable"]["checkpoint_id"]
root_before = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}))
asyncio.run(app.state.thread_store.update_display_name(thread_id, "Root original"))
metadata_before = asyncio.run(app.state.thread_store.get(thread_id))
async def mutation_boundary(request, *, thread_id, as_node, checkpoint_id=None, checkpoint_ns=""):
return gateway_services.build_checkpoint_state_mutation_accessor(request, thread_id=thread_id, as_node=as_node, checkpoint_id=checkpoint_id, checkpoint_ns=checkpoint_ns, state_schema=schema)
monkeypatch.setattr(threads, "build_thread_checkpoint_state_mutation_accessor", mutation_boundary)
with TestClient(app) as client:
checkpoint = {"thread_id": thread_id, "checkpoint_ns": namespace}
if include_checkpoint_id:
checkpoint["checkpoint_id"] = selected_id
response = client.post(
f"/api/threads/{thread_id}/state",
json={"checkpoint": checkpoint, "values": {"title": "Child updated"}},
)
root_response = client.get(f"/api/threads/{thread_id}/state")
search_response = client.post("/api/threads/search", json={})
assert response.status_code == 200, response.text
assert response.json()["values"]["title"] == "Child updated"
assert response.json()["parent_checkpoint_id"] == selected_id
written = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": namespace}}))
assert written.config["configurable"]["checkpoint_id"] == response.json()["checkpoint_id"]
assert written.checkpoint["channel_values"]["title"] == "Child updated"
root_after = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}))
assert root_after.config == root_before.config
assert root_after.checkpoint == root_before.checkpoint
assert root_response.status_code == 200, root_response.text
assert root_response.json()["values"]["title"] == "Root original"
assert asyncio.run(app.state.thread_store.get(thread_id)) == metadata_before
assert search_response.status_code == 200, search_response.text
entry = next(row for row in search_response.json() if row["thread_id"] == thread_id)
assert entry["values"]["title"] == "Root original"
assert entry["metadata"]["branch_title_sequence"] == 2
@pytest.mark.parametrize("mode", ["full", "delta"])
@pytest.mark.parametrize("selector", ["omitted", "checkpoint", "checkpoint_id"])
def test_update_thread_state_root_title_syncs_metadata(monkeypatch, mode, selector) -> None:
from deerflow.agents.thread_state import get_thread_state_schema
app, _store, checkpointer = _build_thread_app()
app.state.checkpoint_channel_mode = mode
thread_id = f"state-root-title-{mode}-{selector}"
schema = get_thread_state_schema(mode)
async def mutation_boundary(request, *, thread_id, as_node, checkpoint_id=None, checkpoint_ns=""):
return gateway_services.build_checkpoint_state_mutation_accessor(request, thread_id=thread_id, as_node=as_node, checkpoint_id=checkpoint_id, checkpoint_ns=checkpoint_ns, state_schema=schema)
monkeypatch.setattr(threads, "build_thread_checkpoint_state_mutation_accessor", mutation_boundary)
with TestClient(app) as client:
created = client.post("/api/threads", json={"thread_id": thread_id, "metadata": {"branch_title_sequence": 2}})
assert created.status_code == 200, created.text
checkpoint = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}))
checkpoint_id = checkpoint.config["configurable"]["checkpoint_id"]
body = {"values": {"title": "Renamed root"}}
if selector == "checkpoint":
body["checkpoint"] = {"thread_id": thread_id, "checkpoint_ns": "", "checkpoint_id": checkpoint_id}
elif selector == "checkpoint_id":
body["checkpoint_id"] = checkpoint_id
response = client.post(f"/api/threads/{thread_id}/state", json=body)
root_response = client.get(f"/api/threads/{thread_id}/state")
search_response = client.post("/api/threads/search", json={})
assert response.status_code == 200, response.text
assert response.json()["values"]["title"] == "Renamed root"
assert response.json()["parent_checkpoint_id"] == checkpoint_id
assert root_response.status_code == 200, root_response.text
assert root_response.json()["values"]["title"] == "Renamed root"
record = asyncio.run(app.state.thread_store.get(thread_id))
assert record["display_name"] == "Renamed root"
assert "branch_title_sequence" not in record["metadata"]
assert search_response.status_code == 200, search_response.text
entry = next(row for row in search_response.json() if row["thread_id"] == thread_id)
assert entry["values"]["title"] == "Renamed root"
assert "branch_title_sequence" not in entry["metadata"]
def test_update_thread_state_rejects_nonexistent_namespace_without_checkpoint_id(monkeypatch) -> None:
app, _store, checkpointer = _build_thread_app()
thread_id = "state-unknown-namespace"
namespace = "child:ghost"
async def mutation_boundary(request, *, thread_id, as_node, checkpoint_id=None, checkpoint_ns=""):
return gateway_services.build_checkpoint_state_mutation_accessor(request, thread_id=thread_id, as_node=as_node, checkpoint_id=checkpoint_id, checkpoint_ns=checkpoint_ns)
monkeypatch.setattr(threads, "build_thread_checkpoint_state_mutation_accessor", mutation_boundary)
with TestClient(app) as client:
assert client.post("/api/threads", json={"thread_id": thread_id, "metadata": {}}).status_code == 200
root_before = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}))
response = client.post(
f"/api/threads/{thread_id}/state",
json={"checkpoint": {"thread_id": thread_id, "checkpoint_ns": namespace}, "values": {"title": "Must not be written"}},
)
assert response.status_code == 404, response.text
assert asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": namespace}})) is None
root_after = asyncio.run(checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}))
assert root_after.config == root_before.config
assert root_after.checkpoint == root_before.checkpoint
def test_update_thread_state_rejects_checkpoint_object_for_another_thread() -> None:
app, _store, _checkpointer = _build_thread_app()
thread_id = "state-checkpoint-thread-mismatch"
with TestClient(app) as client:
created = client.post(
"/api/threads",
json={"thread_id": thread_id, "metadata": {}},
)
assert created.status_code == 200, created.text
response = client.post(
f"/api/threads/{thread_id}/state",
json={
"checkpoint": {
"thread_id": "another-thread",
"checkpoint_ns": "",
"checkpoint_id": "not-used",
},
"values": {"title": "Must not be written"},
},
)
assert response.status_code == 400, response.text
assert response.json()["detail"] == "checkpoint thread_id does not match request thread_id"
@pytest.mark.parametrize("missing_checkpoint_id", ["does-not-exist", ""])
def test_update_thread_state_rejects_missing_explicit_checkpoint_without_writing(
missing_checkpoint_id: str,