diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 2f1fd8ec0..f3213c558 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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 (`...`, 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 `` 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. | diff --git a/backend/app/gateway/routers/thread_runs.py b/backend/app/gateway/routers/thread_runs.py index 5d1069697..76f0fa1de 100644 --- a/backend/app/gateway/routers/thread_runs.py +++ b/backend/app/gateway/routers/thread_runs.py @@ -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, diff --git a/backend/tests/test_thread_regenerate_prepare.py b/backend/tests/test_thread_regenerate_prepare.py index 7672fa1ec..24ff667a9 100644 --- a/backend/tests/test_thread_regenerate_prepare.py +++ b/backend/tests/test_thread_regenerate_prepare.py @@ -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"), [