mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 17:06:05 +00:00
fix(gateway): keep a manual rename through edit and rerun (#4539)
Renaming a conversation and then editing one of its turns reverts the title to whatever it was before that turn ran, so the user's own name for the thread is silently replaced by an older automatically generated one. Edit replay resumes from the checkpoint before the edited turn, and that checkpoint predates the rename. Regenerate already guards against exactly this rollback by replaying the current title as graph input; the edit replay path was added later and did not carry the guard over. Replay the title the same way, but only when the replay base already has one. An untitled base belongs to a thread the title middleware has not named yet — pinning the current title there would keep a name generated from the prompt this edit just replaced, and stop the middleware from naming the rewritten turn.
This commit is contained in:
parent
7a981c309c
commit
919caf7c83
@ -441,7 +441,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores `
|
||||
| **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)`) |
|
||||
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest assistant answer; carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens |
|
||||
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest assistant answer; carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; it carries the current thread title the same way, but only when the replay base already has one — an untitled base belongs to a thread the title middleware has not named yet, so pinning the current title there would keep a name generated from the prompt the edit just replaced; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens |
|
||||
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
|
||||
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
|
||||
| **GitHub Webhooks** (`/api/webhooks/github`) | `POST /` - receive GitHub App / repo webhook deliveries. Verifies `X-Hub-Signature-256` against `GITHUB_WEBHOOK_SECRET`; exempt from auth + CSRF because authenticity is enforced by HMAC. The route is fail-closed: mounted only when `GITHUB_WEBHOOK_SECRET` is set, or when explicit dev opt-in `DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS=1` is set. Recognized events include `ping`, `issues`, `issue_comment`, `pull_request`, `pull_request_review`, and `pull_request_review_comment`; unknown events return 200 with `handled=false`. Fan-out runtime failures return 503, keeping the delivery recorded as failed for manual/API/scripted redelivery (GitHub does not automatically retry any failed delivery, 5xx included); permanent/non-retryable conditions such as `channels.github.enabled: false`, unknown events, malformed payloads, or unavailable channel service return 200 with a skipped/handled response. |
|
||||
|
||||
@ -371,6 +371,11 @@ def _is_terminal_assistant_text_message(message: Any) -> bool:
|
||||
return _is_visible_ai_message(message) and bool(_message_text(message).strip()) and not _message_tool_calls(message)
|
||||
|
||||
|
||||
def _has_title(values: dict[str, Any]) -> bool:
|
||||
title = values.get("title")
|
||||
return isinstance(title, str) and bool(title)
|
||||
|
||||
|
||||
def _has_active_goal(snapshot: Any) -> bool:
|
||||
goal = _checkpoint_values(snapshot).get("goal")
|
||||
return isinstance(goal, dict) and goal.get("status") == "active"
|
||||
@ -662,16 +667,28 @@ async def _prepare_edit_regenerate_payload(
|
||||
"edit_message_id": replacement_human_message_id,
|
||||
"edit_version_group_id": edit_version_group_id,
|
||||
}
|
||||
edit_input: dict[str, Any] = {
|
||||
"messages": [
|
||||
_clean_human_message_for_edit(
|
||||
source_human,
|
||||
replacement_id=replacement_human_message_id,
|
||||
replacement_text=normalized_text,
|
||||
)
|
||||
]
|
||||
}
|
||||
base_values = base_checkpoint_tuple.values if isinstance(getattr(base_checkpoint_tuple, "values", None), dict) else {}
|
||||
latest_values = latest_checkpoint.values if isinstance(latest_checkpoint.values, dict) else {}
|
||||
latest_title = latest_values.get("title")
|
||||
if _has_title(base_values) and isinstance(latest_title, str) and latest_title:
|
||||
# The replay base can predate a manual rename, so replay the current
|
||||
# title rather than letting checkpoint rollback restore the older one
|
||||
# (#4457, the same rollback regenerate already guards against). An
|
||||
# untitled base is deliberately left alone: it belongs to a thread the
|
||||
# title middleware has not named yet, and pinning the current title
|
||||
# there would keep a name generated from the prompt this edit replaced.
|
||||
edit_input["title"] = latest_title
|
||||
return EditRegeneratePrepareResponse(
|
||||
input={
|
||||
"messages": [
|
||||
_clean_human_message_for_edit(
|
||||
source_human,
|
||||
replacement_id=replacement_human_message_id,
|
||||
replacement_text=normalized_text,
|
||||
)
|
||||
]
|
||||
},
|
||||
input=edit_input,
|
||||
checkpoint=checkpoint,
|
||||
metadata=metadata,
|
||||
target_run_id=target_run_id,
|
||||
|
||||
@ -629,6 +629,75 @@ def test_prepare_edit_regenerate_payload_returns_new_human_and_edit_metadata():
|
||||
}
|
||||
|
||||
|
||||
def _edit_source_run_fixtures() -> tuple[FakeEventStore, FakeRunManager]:
|
||||
event_store = FakeEventStore(
|
||||
[
|
||||
{
|
||||
"run_id": "run-old",
|
||||
"event_type": "llm.ai.response",
|
||||
"category": "message",
|
||||
"content": {"id": "ai-1", "type": "ai", "content": "answer v1"},
|
||||
"metadata": {"caller": "lead_agent"},
|
||||
}
|
||||
]
|
||||
)
|
||||
run_manager = FakeRunManager([SimpleNamespace(run_id="run-old", status=RunStatus.success, metadata={}, last_ai_message="answer v1")])
|
||||
return event_store, run_manager
|
||||
|
||||
|
||||
def test_prepare_edit_regenerate_payload_preserves_a_rename_the_replay_base_predates():
|
||||
"""Replaying a turn must not roll the thread back to an older title (#4457)."""
|
||||
from app.gateway.routers import thread_runs
|
||||
|
||||
human = HumanMessage(id="human-1", content="original question")
|
||||
ai = AIMessage(id="ai-1", content="answer v1")
|
||||
base = _checkpoint("ckpt-base", [])
|
||||
base.checkpoint["channel_values"]["title"] = "auto generated title"
|
||||
latest = _checkpoint("ckpt-ai", [human, ai])
|
||||
latest.checkpoint["channel_values"]["title"] = "User renamed title"
|
||||
event_store, run_manager = _edit_source_run_fixtures()
|
||||
|
||||
response = asyncio.run(
|
||||
thread_runs._prepare_edit_regenerate_payload(
|
||||
"thread-1",
|
||||
"human-1",
|
||||
"edited question",
|
||||
_request(FakeCheckpointer([latest, base]), event_store, run_manager=run_manager),
|
||||
)
|
||||
)
|
||||
|
||||
assert response.checkpoint["checkpoint_id"] == "ckpt-base"
|
||||
assert response.input["title"] == "User renamed title"
|
||||
|
||||
|
||||
def test_prepare_edit_regenerate_payload_lets_an_untitled_base_name_the_edited_turn():
|
||||
"""A base with no title belongs to a thread that has not been named yet.
|
||||
|
||||
Pinning the current title there would keep a name generated from the prompt
|
||||
the edit just replaced, so leave the channel empty and let the title
|
||||
middleware name the rewritten turn.
|
||||
"""
|
||||
from app.gateway.routers import thread_runs
|
||||
|
||||
human = HumanMessage(id="human-1", content="original question")
|
||||
ai = AIMessage(id="ai-1", content="answer v1")
|
||||
latest = _checkpoint("ckpt-ai", [human, ai])
|
||||
latest.checkpoint["channel_values"]["title"] = "title of the replaced question"
|
||||
event_store, run_manager = _edit_source_run_fixtures()
|
||||
|
||||
response = asyncio.run(
|
||||
thread_runs._prepare_edit_regenerate_payload(
|
||||
"thread-1",
|
||||
"human-1",
|
||||
"edited question",
|
||||
_request(FakeCheckpointer([latest, _checkpoint("ckpt-base", [])]), event_store, run_manager=run_manager),
|
||||
)
|
||||
)
|
||||
|
||||
assert response.checkpoint["checkpoint_id"] == "ckpt-base"
|
||||
assert "title" not in response.input
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("replacement_text", "detail"),
|
||||
[
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user