bytedance/deer-flow · warning · HTTPException

Only the latest completed user turn can be edited

Error message

Only the latest completed user turn can be edited

What it means

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.

Source

Thrown at backend/app/gateway/routers/thread_runs.py:442

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"


def _latest_editable_turn(messages: list[Any], human_message_id: str) -> tuple[int, Any, int, Any, list[str]]:
    latest_human_index = next((index for index in range(len(messages) - 1, -1, -1) if _is_visible_human_message(messages[index])), None)
    if latest_human_index is None or _message_id(messages[latest_human_index]) != human_message_id:
        raise HTTPException(status_code=409, detail="Only the latest completed user turn can be edited")

    source_human = messages[latest_human_index]
    last_ai_index: int | None = None
    for index, message in enumerate(messages[latest_human_index + 1 :], start=latest_human_index + 1):
        if _is_visible_human_message(message):
            break
        if _is_visible_ai_message(message):
            last_ai_index = index

    if last_ai_index is None or not _is_terminal_assistant_text_message(messages[last_ai_index]):
        raise HTTPException(status_code=409, detail="Only completed assistant text turns can be edited")

    source_message_ids = [message_id for message in messages[latest_human_index : last_ai_index + 1] if (message_id := _message_id(message))]
    return latest_human_index, source_human, last_ai_index, messages[last_ai_index], source_message_ids


def _event_message_id(row: dict[str, Any]) -> str | None:
    content = row.get("content")

View on GitHub (pinned to 1dd6ba1acb)

Solutions

  1. Refetch the thread state and use the id of the latest visible human message from the current checkpoint.
  2. Ensure only one client operates on the thread at a time, or refresh the thread view before initiating an edit.
  3. Confirm the message id you pass is the server-side id (from the checkpointed messages), not a client-local placeholder id.
  4. After a regenerate, use the newly stamped message ids rather than cached ones.

Example fix

// before
await editPrepare(threadId, oldHumanMessageId, newText);
// after
const {messages} = await getThreadState(threadId);
const lastHuman = [...messages].reverse().find(m => m.type === 'human');
if (lastHuman.id !== oldHumanMessageId) oldHumanMessageId = lastHuman.id;
await editPrepare(threadId, oldHumanMessageId, newText);
Defensive patterns

Strategy: validation

Validate before calling

const {messages} = await getThreadState(threadId);
const lastHuman = [...messages].reverse().find(m => m.type === 'human' && !m.hidden);
if (lastHuman?.id !== requestedHumanId) {
  // refresh UI to the latest turn instead of editing
}

Type guard

function isLatestVisibleHuman(messages: Msg[], id: string): boolean {
  for (let i = messages.length - 1; i >= 0; i--) {
    const m = messages[i];
    if (m.type === 'human' && !m.hidden) return m.id === id;
  }
  return false;
}

Try / catch

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; }

Prevention

When it happens

Trigger: 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'.

Common situations: 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).

Related errors


AI-assisted analysis of bytedance/deer-flow@1dd6ba1acb (2026-08-14). Data as JSON: /api/errors/54db2e74e6804d4d. Report an issue: GitHub.