{"record":{"id":"54db2e74e6804d4d","repo":"bytedance/deer-flow","slug":"only-the-latest-completed-user-turn-can-be-edited","errorCode":null,"errorMessage":"Only the latest completed user turn can be edited","messagePattern":"Only the latest completed user turn can be edited","errorType":"http","errorClass":"HTTPException","httpStatus":409,"severity":"warning","filePath":"backend/app/gateway/routers/thread_runs.py","lineNumber":442,"sourceCode":"\ndef _is_terminal_assistant_text_message(message: Any) -> bool:\n    return _is_visible_ai_message(message) and bool(_message_text(message).strip()) and not _message_tool_calls(message)\n\n\ndef _has_title(values: dict[str, Any]) -> bool:\n    title = values.get(\"title\")\n    return isinstance(title, str) and bool(title)\n\n\ndef _has_active_goal(snapshot: Any) -> bool:\n    goal = _checkpoint_values(snapshot).get(\"goal\")\n    return isinstance(goal, dict) and goal.get(\"status\") == \"active\"\n\n\ndef _latest_editable_turn(messages: list[Any], human_message_id: str) -> tuple[int, Any, int, Any, list[str]]:\n    latest_human_index = next((index for index in range(len(messages) - 1, -1, -1) if _is_visible_human_message(messages[index])), None)\n    if latest_human_index is None or _message_id(messages[latest_human_index]) != human_message_id:\n        raise HTTPException(status_code=409, detail=\"Only the latest completed user turn can be edited\")\n\n    source_human = messages[latest_human_index]\n    last_ai_index: int | None = None\n    for index, message in enumerate(messages[latest_human_index + 1 :], start=latest_human_index + 1):\n        if _is_visible_human_message(message):\n            break\n        if _is_visible_ai_message(message):\n            last_ai_index = index\n\n    if last_ai_index is None or not _is_terminal_assistant_text_message(messages[last_ai_index]):\n        raise HTTPException(status_code=409, detail=\"Only completed assistant text turns can be edited\")\n\n    source_message_ids = [message_id for message in messages[latest_human_index : last_ai_index + 1] if (message_id := _message_id(message))]\n    return latest_human_index, source_human, last_ai_index, messages[last_ai_index], source_message_ids\n\n\ndef _event_message_id(row: dict[str, Any]) -> str | None:\n    content = row.get(\"content\")","sourceCodeStart":424,"sourceCodeEnd":460,"githubUrl":"https://github.com/bytedance/deer-flow/blob/1dd6ba1acb03700589994b0366c5d1c7d05e2eff/backend/app/gateway/routers/thread_runs.py#L424-L460","documentation":"Raised by _latest_editable_turn (HTTP 409) during edit-regenerate when the supplied human_message_id does not match the id of the most recent visible human message in the checkpoint's message list. The edit flow only permits rewriting the last completed user turn; anything older is refused.","triggerScenarios":"POST to the edit/prepare endpoint with a human_message_id from an earlier turn while newer turns exist; the UI sends a stale message id after the thread advanced (e.g. another tab or device sent a message); the last human message is hidden from UI (hide_from_ui) so the id supplied is not the one the server considers 'latest visible'.","commonSituations":"Stale client state after a run finished in another session; message id confusion between client-generated and server-stamped ids; trying to edit a turn that was already regenerated (its message got a new id).","solutions":["Refetch the thread state and use the id of the latest visible human message from the current checkpoint.","Ensure only one client operates on the thread at a time, or refresh the thread view before initiating an edit.","Confirm the message id you pass is the server-side id (from the checkpointed messages), not a client-local placeholder id.","After a regenerate, use the newly stamped message ids rather than cached ones."],"exampleFix":"// before\nawait editPrepare(threadId, oldHumanMessageId, newText);\n// after\nconst {messages} = await getThreadState(threadId);\nconst lastHuman = [...messages].reverse().find(m => m.type === 'human');\nif (lastHuman.id !== oldHumanMessageId) oldHumanMessageId = lastHuman.id;\nawait editPrepare(threadId, oldHumanMessageId, newText);","handlingStrategy":"validation","validationCode":"const {messages} = await getThreadState(threadId);\nconst lastHuman = [...messages].reverse().find(m => m.type === 'human' && !m.hidden);\nif (lastHuman?.id !== requestedHumanId) {\n  // refresh UI to the latest turn instead of editing\n}","typeGuard":"function isLatestVisibleHuman(messages: Msg[], id: string): boolean {\n  for (let i = messages.length - 1; i >= 0; i--) {\n    const m = messages[i];\n    if (m.type === 'human' && !m.hidden) return m.id === id;\n  }\n  return false;\n}","tryCatchPattern":"try { await editPrepare(threadId, humanId, text); } catch (e) { if (e.status === 409 && /latest completed user turn/.test(e.detail)) { await refreshThread(threadId); } else throw e; }","preventionTips":["Refresh thread state right before edit","Disable Edit on all but the latest user turn","Use server-assigned message ids"],"tags":["edit","regenerate","stale-state","http-409"],"backgroundTag":null,"analyzedSha":"1dd6ba1acb03700589994b0366c5d1c7d05e2eff","analyzedAt":"2026-08-14T21:20:34.804Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}