Skip to content

fix(history): early user messages vanish or jump mid-run when pagination and context compaction overlap - #4696

Open
rayhpeng wants to merge 9 commits into
mainfrom
rayhpeng/fix-4666-seq-ordering
Open

fix(history): early user messages vanish or jump mid-run when pagination and context compaction overlap#4696
rayhpeng wants to merge 9 commits into
mainfrom
rayhpeng/fix-4666-seq-ordering

Conversation

@rayhpeng

@rayhpeng rayhpeng commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Refs #4606, #4508, #4363

Summary

While a long task runs, the user's earlier message (usually the thread's first) disappears from the transcript or jumps into the middle of the step stream, alternating between the two; a full reload after the run heals it, which is why this stayed hard to pin down. It triggers only when both hold: the feed has grown past one history page (>50 rows, so GET /messages/page no longer contains the message) and context compaction has fired (so the checkpoint keeps only [hidden reminder, user message, recent tail]). Uploading a file is not part of the mechanism — it just makes the task heavy enough to hit both quickly.

Root cause: the transcript is merged from two sources — the feed (run_events, append-only, seq-ordered) and the checkpoint (values frames, compacted, no position info). Once the message falls out of the loaded feed window, its checkpoint copy has no anchor and no seq, so the frontend can only guess: guess fails → dropped (vanishes, the #4065 "suppress" path); heuristics re-correct → lands mid-stream (#4660's reconnect fix detects a real misplacement signal but, with no ground truth, moves steps to the wrong side). The backend was never at fault — across 900+ captured checkpoints the message order is correct in every one.

Fix

Stamp the server-authoritative feed position onto every persisted message at the exit: when the worker serializes a root values frame (and on /state, /history reads), it resolves each message's feed seq by identity and attaches additional_kwargs.deerflow_seq. The frontend then places below-window messages by seq instead of guessing.

flowchart TB
    ES[("run_events — (seq, content.id) mapping already exists")] --> W
    W["worker: serialize root values frame"] --> CK{"ids not in run cache?"}
    CK -->|"no (incremental)"| SKIP["zero queries"]
    CK -->|"yes (compaction frame)"| Q["one batch query per run"]
    SKIP --> STAMP["stamp additional_kwargs.deerflow_seq"]
    Q --> STAMP
    STAMP --> FE["frontend: seq below window → head ✅"]
    style FE fill:#e1f5e1
Loading

Alternatives considered:

Idea Why not
A Compaction keeps full state, prune at model-request time (deepagents#2876) Unbounded checkpoint growth (worsens #4138); upstream closed it not planned; langchain 1.3.14 itself rewrites state with REMOVE_ALL — no answer to copy
B Frontend just stops dropping Downgrades "vanish" to "misplaced"; kept only as the no-seq fallback (insert before anchor, never drop, never tail-append)
C Write seq into the checkpoint at compaction time Middleware has no store access; branching re-seeds the feed with new seqs (#4380), so a welded-in seq goes stale — worse than none. Also unsound in general: seq is assigned at event-store write time, after the checkpoint is persisted
D (chosen) Stamp at the exit (worker) Same precise timing and mapping reuse as C without its traps; measured cost is one batch query per run; no SSE protocol change (additional_kwargs like run_id/hide_from_ui); first step toward replacing ~500 lines of anchor heuristics with a pure seq sort

Commits

Commit Layer Change
ab99337e both Stop-the-bleed: journal short-circuit compensation drops its allowlist; mergeMessages stops dropping the pre-anchor prefix
1aeee4b6 backend RunEventStore.get_message_seqs() + shared identity rule (all three stores)
8eec0c2b backend _MessageSeqStamper stamps root values frames
442fec89 backend Strip client-echoed deerflow_seq (server-owned field)
62bfbac7 frontend Place below-window messages by seq instead of anchor guessing
be72b9d8 frontend Zero-shared-anchor case (fully disjoint compacted checkpoint) also routes through seq placement
f2cbf8da backend /state and /history reads stamp seq too, not only stream frames
2a9c4d16 backend Move the __user-suffix helpers to utils.messages, breaking the import cycle 1aeee4b6 exposed

Safety boundaries: root frames only; no seq resolved → no stamp (streaming messages are correctly tail-appended anyway); query failure → warn and publish unstamped. The identity rule is deliberately mirrored between runtime/events/message_identity.py and hooks.ts::messageIdentity — a mismatch fails silently.

Verification

  • Deterministic e2e (marked first message + forced multi-turn task): position went vanished#13 (stop-the-bleed only) → #0 with the full chain. Frame-by-frame SSE decoding confirms all 25 values frames were correct — the bug is 100% in the frontend merge.
  • Real user reproduction replay: 626 checkpoints from a live capture, replayed through the actual frontend pipeline — main loses the message in 368/626 frames under the paged window; this branch places it correctly in 626/626.
  • Live browser A/B (300 ms DOM monitor): main-behavior code oscillates (middle ↔ gone, matching the user's recording); this branch shows zero anomalies under stricter conditions (compaction fired 10×, zero shared anchors, has_more=true).
  • Suites: frontend 989 passed (tsc + eslint clean); backend 10848 passed / 23 failed — all 23 reproduce identically on the base commit (env-dependent), unrelated to this branch.

Reviewer notes

  1. ab99337e rewrites an explicit fix(context): resolve context compress bug #4065 test contract (the "unloaded gap before first anchor" message was asserted to be dropped). Dropping fixed "old message appended to tail"; this PR takes the third path — keep, insert before the anchor, never tail-append — preserving fix(context): resolve context compress bug #4065's invariant. Flagging for @AnoobFeng / @Vanzeren.
  2. _MessageSeqStamper wiring is unit-tested at _publish_stream_item level; the wiring itself relies on e2e coverage.
  3. Deliberately not included: a gap placeholder UI ("⬆ N earlier messages not loaded").

Out of scope: thread-title pollution from the UploadsMiddleware rewrite ("<current_uploads>…" as title — reproduced in both captures) is an independent bug with its own fix branch, follow-up PR.

rayhpeng and others added 8 commits August 4, 2026 18:22
…d page window

Two independent paths made a user's own message disappear from a long thread
(#4666, #4508, #4363). Both are reproduced by a real two-round run: once the
thread passes the 50-row `/messages/page` window AND context compaction fires,
the two sources of truth stop overlapping at the head.

1. Middleware-answered tool results never reached the event store. A middleware
   that short-circuits a tool call (e.g. ReadBeforeWriteMiddleware's blocked
   write) returns a user-visible ToolMessage, but LangChain never emits
   `on_tool_end`, so RunJournal never persisted it — the user saw it during the
   run and it vanished on reload. RunJournal already reconciles final-output
   tool messages, but only for an `ask_clarification` allowlist. The allowlist
   is removed; scope stays bounded by the three conditions that actually matter
   (visible, this run's lead agent, not already persisted), so subagent results
   still stay in their own step feed.

2. mergeMessages discarded the checkpoint prefix before the first shared anchor.
   #4065 correctly established that a summarization-rescued early message must
   not be appended to the tail, and suppressed it instead. That suppression is
   what deletes the message when the first history page no longer reaches back
   to it. It is now woven in before the first shared anchor — the one position
   both the checkpoint and seq-sorted history agree on — so #4065's invariant
   (never the tail) still holds. A collapsed unloaded gap is recoverable by
   paging; a dropped message is not.

Verified against real captured payloads from the reproducing run: the first user
message returns to the transcript. Its exact position is still approximate —
after compaction the live window carries too few anchors to place it precisely,
which only seq-based ordering can close.

Backend: 10809 passed (baseline 10808; same 15 pre-existing failures in
browser/crawler community tools). Frontend: 986 passed, typecheck + eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for placing checkpoint messages in the seq-ordered thread feed
(#4666). A checkpoint carries no seq of its own and loses messages to
summarization, so once the feed's 50-row page window no longer reaches back to a
surviving old message, a client has nothing to place it by. The seq already
exists in run_events keyed by the message id — this exposes it without paging
the whole feed.

`message_identity` is the backend half of the identity rule the frontend applies
in `hooks.ts::messageIdentity`: a ToolMessage is keyed by `tool_call_id`, and
DynamicContextMiddleware's `X` / `X__user` human copies collapse to one identity.
The two halves must stay in sync — a mismatch is silent, degrading placement
rather than raising.

`get_message_seqs` is implemented for all three stores. Misses are absent from
the result rather than an error, so callers degrade to their own placement rule;
the earliest seq wins when one identity resolves to several rows, so a
re-persisted message keeps the position it first occupied. The DB store decodes
rows in Python because `content` is a TEXT column holding a JSON string, not a
JSON column — the identity fields cannot be projected in SQL.

Nothing consumes this yet; no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Attaches `additional_kwargs.deerflow_seq` to messages in a root `values` frame
that the thread feed already holds, so a client can place a message the
checkpoint kept but its loaded history page window no longer reaches (#4666).
Nothing is written back to the checkpoint: the seq is added when the frame is
serialized and belongs to that frame only.

Cost is bounded to frames introducing identities the run has not resolved yet.
Messages this run produces are not in the feed while streaming, so they are
looked up once, recorded as misses, and never retried — in a real run the only
frame that pays for a query is the one where compaction brings older messages
back into view. Measured on a reproducing two-round run: 1 lookup across 25
values frames.

The stamper is built once per run rather than per `_stream_once`, or a goal
continuation would discard the resolved seqs. Subgraph frames are not stamped:
a subagent's snapshot is not part of this thread's feed ordering. A lookup
failure logs and leaves the frame unstamped rather than failing it — placement
is an enhancement and clients fall back to their own ordering rule.

Frontend does not read the field yet; no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`deerflow_seq` is display metadata the Gateway attaches when it serializes a
values frame. A client replaying messages (regenerate / edit-and-rerun) would
otherwise write it into the checkpoint, where it becomes wrong the moment the
thread is forked — a branch re-seeds its feed and reassigns seq (#4380).

Joins the existing server-owned key set, so it follows the same trusted-internal
rule as the dynamic-context and view-image markers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…arest anchor

Completes #4666. Weaving a compaction-rescued message before the first shared
anchor keeps it in the transcript, but not in the right place: after compaction
the live window carries too few anchors, and the nearest one can sit deep inside
the loaded page window — measured at row 25 of 50 on a reproducing run, which is
why the first user turn rendered mid-transcript instead of at the head.

Both sides now carry the backend's thread-global seq. `buildVisibleHistoryMessages`
copies each row's `seq` onto the message (same shape as the existing `run_id`),
and the Gateway stamps it onto `values` frame messages it has already persisted.
A live message whose seq is below the loaded window's lower bound is placed ahead
of everything on screen rather than before the nearest anchor. A message with no
seq — still streaming, so not in the feed yet — keeps the weaving path, since the
tail is already its correct position.

Verified against the captured payloads of the reproducing run: the first user
message goes from absent, to #13 (behind the second question), to #0.

Frontend: 988 passed, typecheck + eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hor is shared

Also #4666. Placing a compaction-rescued message by its feed seq was gated on
reaching a shared anchor, because the split ran inside the anchor walk. When the
loaded page and the live checkpoint share no identity at all, that walk never
runs and the message fell through to `[...canonical, ...live]` — appended after
the entire window, the one arrangement #4065 proved wrong, with its seq known
the whole time.

That is not a corner case. Open an old, already-summarized conversation and send
a message: the page on screen is the newest rows from before that turn, while
the checkpoint holds the rescued first user turn plus steps of the new run that
are not in the feed yet. On a reproducing run the two sides shared zero anchors
and the user's own first question rendered at row 50 of 50 — the reported
"first message jumps to the bottom".

Split `beforeWindow` out of `live` before walking anchors, walk `liveInWindow`,
and use it for the no-anchor branch as well, so a message routed ahead of the
window is not re-appended at the tail by dedup.

Measured on captured payloads of a reproducing run (real gateway, real
compaction), first user message position:

  no shared anchor:  row 50 -> row 0, seq order monotonic again
  shared anchors:    row 0 -> row 0 (unchanged)
  paged to the top:  row 0 -> row 0 (unchanged)

Regression test verified red-green: reverting the fix fails it with the message
rendered after the window.

Frontend: 989 passed, eslint + tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y on stream frames

Completes #4666. `_MessageSeqStamper` sits on the streaming publish path, so a
client that joins a live run learns where a summarization-rescued turn belongs
while a client that merely opens the conversation does not — and opening is the
common case. `GET /threads/{id}/state` and `POST /threads/{id}/history` returned
the checkpoint with no seq at all, so the merge fell back to the nearest shared
anchor, which after summarization sits deep inside the loaded page.

Reproduced in a browser against a real gateway, on a thread that had already
compacted: the user's first question rendered at row 320 of 389, behind the
newest question instead of at the head. Both reads showed 0 of 13 messages
carrying a seq. That is the reported symptom, still present after the streaming
fix.

Add `stamp_messages_with_seq`, the request-scoped counterpart of the stamper:
everything a checkpoint still holds is already persisted, so one batched lookup
resolves the whole list and there is nothing to retry later. Resolve the store
through `_optional_run_event_store` rather than `get_run_event_store`, because
seq is placement metadata — a deployment without a feed must still be able to
read a thread.

After the fix, on the same thread in the same browser: 13 of 13 messages carry a
seq and the first question renders at the head, ahead of the newest one.

Backend: ruff clean, 326 passed across the touched suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…messages to break an import cycle

message_identity imported strip_injected_user_message_id_suffix from the
dynamic-context middleware, closing a cycle (middleware -> deerflow.runtime
-> worker -> events -> middleware) that only stayed hidden while an earlier
import happened to break it. Define INJECTED_USER_MESSAGE_ID_SUFFIX and the
strip helper in deerflow.utils.messages and re-export them from the
middleware so existing importers keep working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added area:agents Agents, subagents, graph wiring, prompts, langgraph.json area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only area:frontend Next.js frontend under frontend/ needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/XL PR changes 700+ lines labels Aug 5, 2026
@rayhpeng

rayhpeng commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

概要

长任务运行中,用户较早的消息(通常是第一条)会消失窜到步骤流中部,两种形态交替;任务结束后重载自愈,因此长期难定位。触发需两个条件同时成立:feed 超过一页(>50 行,/messages/page 不再包含该消息)压缩已发生(checkpoint 只剩 [隐藏 reminder, 用户消息, 近期 tail])。上传文件不是机制本质,只是让任务更快同时命中两个条件。

根因:消息流由两个源合并——feed(run_events,append-only、seq 有序)和 checkpoint(values 帧,会被压缩、不带位置)。消息掉出已加载的 feed 窗口后,它的 checkpoint 副本既无锚点也无 seq,前端只能猜:猜不出 → 丢弃(消失,#4065 的 suppress 路径);启发式再修正 → 落到流中部(#4660 的重连修正检测到的错位信号是真的,但没有地面真相,把步骤搬错了方向)。后端从未出错——两个真实现场 900+ 个 checkpoint 里消息顺序全部正确。

修复

在出口给每条已持久化消息贴上服务端权威位置:worker 序列化根 values 帧时(以及 /state/history 读取时),按 identity 反查 feed seq,挂 additional_kwargs.deerflow_seq;前端对低于窗口下界的消息按 seq 定位,不再猜。整个 run 实测只需一次批量查询(仅压缩帧会带回未见过的 id,其余命中 run 级缓存)。

选型对比(A/B/C 为何不行):

思路 未采用原因
A 压缩不删 state,模型请求层裁剪(deepagents#2876) checkpoint 无限膨胀(恶化 #4138);上游关为 not planned;langchain 1.3.14 自身也用 REMOVE_ALL 重写 state,无现成答案
B 前端不再丢弃 只把"消失"降级为"错位";保留为查不到 seq 时的兜底(插锚点前,绝不丢弃/追尾)
C 压缩时把 seq 写进 checkpoint middleware 拿不到 store;分叉会重新分配 seq(#4380),焊死的旧 seq 比没有更糟;且 seq 在 event store 写入时才分配,晚于 checkpoint 落盘,构造上不成立
D(采用) worker 出口贴 seq 继承 C 的时机与映射复用、避开其全部坑;不改 SSE 协议(additional_kwargsrun_id/hide_from_ui 同构);是未来用纯 seq 排序替掉约 500 行锚点启发式的第一步

Commit 明细

commit 改动
ab99337e 前后端 止血:journal 短路补偿去 allowlist;mergeMessages 不再丢弃锚点前缀
1aeee4b6 后端 get_message_seqs() + 前后端共享的 identity 规则(三个 store)
8eec0c2b 后端 _MessageSeqStamper:根 values 帧挂 seq
442fec89 后端 剥离客户端回传的 deerflow_seq(服务端专有)
62bfbac7 前端 低于窗口下界的消息按 seq 定位
be72b9d8 前端 0 共享锚点场景同样走 seq 定位
f2cbf8da 后端 /state/history 读取同样 stamp
2a9c4d16 后端 __user 后缀助手下沉 utils.messages,解 1aeee4b6 暴露的 import 环

安全边界:只处理根帧;查不到 seq 不挂(流式中的消息本就该追加尾部);查询失败只告警、帧照发。identity 规则在 runtime/events/message_identity.pyhooks.ts::messageIdentity 两侧镜像——不一致会静默失效。

验证

  • 确定性 e2e(标记首条消息 + 强制多轮任务):位置从消失 → 仅止血后 #13 → 完整链路 #0;SSE 逐帧解码证明 25 个 values 帧全部正确——bug 100% 在前端合并层。
  • 真实现场重放:用户复现 thread 的 626 个 checkpoint 走真实前端管线——main 在翻页窗口下 368/626 帧丢失该消息;本分支 626/626 全部正确置顶
  • 真实浏览器 A/B(300ms DOM 监视器):main 行为侧抖动(中部↔消失,与用户录屏吻合);本分支在更苛刻条件下(压缩 10 次、0 共享锚点、has_more=true)零异常
  • 测试套件:前端 989 passed(tsc + eslint 干净);后端 10848 passed / 23 failed——23 个在 base commit 上完全复现(环境相关),与本分支无关。

Reviewer 注意

  1. ab99337e 改写了 fix(context): resolve context compress bug #4065 的一条明确测试契约(该消息曾被断言应该丢弃)。丢弃当年是为修"老消息追加到尾部";本 PR 走第三条路——保留、插到锚点、绝不追尾——保住 fix(context): resolve context compress bug #4065 的不变量。请 @AnoobFeng / @Vanzeren 过目。
  2. _MessageSeqStamper 接线只有 _publish_stream_item 层单测,接线本身靠 e2e 覆盖。
  3. 刻意未做:gap 占位 UI("⬆ 还有 N 条较早消息未加载")。

范围外:UploadsMiddleware 改写导致的会话标题污染(标题变成 "<current_uploads>…",两次现场均复现)是独立 bug,已有单独修复分支,后续专门提 PR。

try:
found = await store.get_message_seqs(thread_id, sorted(wanted))
except Exception:
logger.warning("Failed to resolve message seqs for thread %s", thread_id, exc_info=True)
@rayhpeng rayhpeng linked an issue Aug 5, 2026 that may be closed by this pull request
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:agents Agents, subagents, graph wiring, prompts, langgraph.json area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only area:frontend Next.js frontend under frontend/ needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/XL PR changes 700+ lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

前端消息

2 participants