deer-flow/frontend/tests/unit/core/threads/message-merge.test.ts
rayhpeng cd35363a05
fix(history): early user messages vanish or jump mid-run when pagination and context compaction overlap (#4696)
* fix(history): stop dropping user messages that fall outside the loaded 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>

* feat(events): look up a persisted message's seq by identity

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>

* feat(runtime): carry each persisted message's feed seq on values frames

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>

* fix(gateway): strip the server-owned message seq from untrusted input

`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>

* fix(frontend): place a checkpoint message by its feed seq, not its nearest 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>

* fix(frontend): place a pre-window checkpoint message even when no anchor 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>

* fix(gateway): stamp the message feed seq on checkpoint reads, not only 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>

* refactor(harness): move the injected-user-id suffix helpers to utils.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>

* fix(docs): improve formatting and clarity in AGENTS.md and message-merge.test.ts

* perf(events): stop the seq scan once every wanted identity is resolved

Rows past the last wanted seq can only be re-persisted copies that
already lose the earliest-seq-wins tiebreak, so all three stores now
break out of the scan (and the db store out of its per-row JSON
decoding) once found covers wanted. Matters most for /state and
/history reads of long threads, where this lookup runs with no run
cache and a typically tiny wanted set.

Raised by review on #4696.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(events): share the seq-stamping expression between the two stampers

The walrus-plus-merge expression was duplicated verbatim between
stamp_messages_with_seq and _MessageSeqStamper.stamp — two counterparts
of one rule where silent divergence is the likely failure mode if only
one side is edited. Both now call attach_message_seq next to
MESSAGE_SEQ_KEY in message_identity.py. The trailing
isinstance(message, Mapping) guard was unreachable (a non-Mapping entry
already got identity = None) and is gone with the extraction.

Raised by review on #4696.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(events): seq stamping survives launch paths without user context

The db store's get_message_seqs defaults to user_id=AUTO, which raises
when no user is in the contextvar — the first strict-AUTO read ever
called from the worker context. On a launch path that never inherits
the auth context (e.g. a null-owner scheduled task), stamp()'s except
clause swallowed that into a per-frame warning and silently disabled
seq stamping for exactly the background runs that need it.

The stamper now soft-resolves the user id once at build time — the
same rule as the worker's write paths beside it (unset -> no filter)
— and passes it explicitly. jsonl/memory stores gain the same
user_id kwarg the base list_messages contract already carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(events): SQL-prefilter the message seq lookup's candidate rows

get_message_seqs scanned and JSON-decoded every message row of the
thread: the early exit never fires when a wanted identity is absent
from the feed (a message still streaming, or checkpoint-only), and
/state / /history reads want the newest messages, so the ascending
scan traversed essentially the whole feed — with the content column
carrying full tool outputs, that is heavy I/O plus N JSON parses on
exactly the long threads this lookup exists for.

A LIKE prefilter now keeps that cost in SQL: only rows containing a
wanted raw id as a substring are fetched and decoded. False positives
are re-checked by message_identity; LIKE wildcards are escaped; an id
json.dumps would escape (breaking the verbatim-substring guarantee)
falls the whole set back to the full scan rather than silently
missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(agents): sink runtime mechanism docs below the gateway guidance budget

Merging main pushed backend/app/gateway/AGENTS.md past its 40KB soft
budget (main had left 81 bytes of headroom). Per the nearest-file rule,
move the mechanism detail of the message-seq stamping and run-delivery
receipt sections — both owned by runtime/ code — into
packages/harness/deerflow/runtime/AGENTS.md, leaving the gateway file
the REST-surface summary and a pointer. The seq section also documents
the stamper's build-time soft user-id resolution and the db store's SQL
prefilter from the review follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(agents): sink durable-MCP task detail below the backend guidance budget

Merging main pushed backend/AGENTS.md past its 24KB module soft budget
(main itself is at 24762 after #4848 — this branch adds zero net bytes
to the file). Per the nearest-file rule, move the two durable-MCP task
runtime bullets' mechanism detail into
packages/harness/deerflow/mcp/AGENTS.md, leaving summaries and
pointers; this also restores ~2KB of headroom so the next merge does
not trip the same wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(events): re-ask a message-seq miss once the feed advances

The run-scoped stamper cached lookup misses for the whole run. A message
this run produces reaches a values frame before RunJournal flushes it, so
its first lookup legitimately misses — and the journal persists it moments
later, giving it a feed seq the stamper never asks for again. A long run
that afterwards rolls past the history page and compacts then carries that
message unstamped, back to the approximate anchor placement this stamper
exists to replace (#4666). A transient store error had the same permanent
effect, since the except clause degrades to an empty result.

A miss is now provisional while a hit stays final: RunJournal counts its
successful event-store writes as `feed_generation`, and the stamper re-asks
a missed identity only once that counter moves. Retrying is therefore
bounded by feed writes rather than by frames — the per-frame query the
run-scoped cache was built to avoid — and a failed lookup costs one
generation instead of the run.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:04:17 +08:00

2305 lines
64 KiB
TypeScript

import type { Message } from "@langchain/langgraph-sdk";
import { expect, rs, test } from "@rstest/core";
import { InfiniteQueryObserver, QueryClient } from "@tanstack/react-query";
import {
buildThreadMessagesPageUrl,
buildVisibleHistoryMessages,
areOptimisticMessagesConfirmed,
computeSummarizationTransientMessages,
countHumanMessagesExcludingSuperseded,
flattenThreadHistoryPages,
getSummarizationMiddlewareMessages,
getThreadHistoryNextPageParam,
getVisibleOptimisticMessages,
mergeRenderedMessageLedger,
mergeTransientHistoryBridge,
mergeTransientHistoryBridgeOrder,
mergeMessages,
parseThreadMessagesPageResponse,
pruneConfirmedTransientMessages,
reconcileThreadHistoryRows,
removeSetItems,
resolveThreadTransientHistoryBridge,
resolveTransientHistoryBridge,
restoreLocalTurnMessageOrder,
restoreReconnectedTurnMessageOrder,
type ThreadMessagesPageResponse,
} from "@/core/threads/hooks";
import type { RunMessage } from "@/core/threads/types";
function runMessage(seq: number): RunMessage {
return {
run_id: "run-1",
seq,
content: {} as Message,
metadata: { caller: "" },
created_at: "2026-05-22T00:00:00Z",
};
}
test("mergeMessages removes duplicate messages already present in history", () => {
const human = {
id: "human-1",
type: "human",
content: "Design an agent",
} as Message;
const ai = {
id: "ai-1",
type: "ai",
content: "Let's design it.",
} as Message;
expect(mergeMessages([human, ai, human, ai], [], [])).toEqual([human, ai]);
});
test("mergeMessages keeps a protected early message before the first shared anchor instead of dropping it", () => {
// #4065 established that an early message rescued by summarization must not
// be appended to the tail: its canonical position is earlier, and the tail is
// provably wrong. Suppressing it entirely was the other half of that fix, and
// it is how a user's own question disappeared from a long thread once the
// first history page no longer reached back to it (#4666).
//
// Both concerns hold at once: the message stays before the first shared
// anchor (never the tail), which is the one position both the checkpoint and
// seq-sorted history agree on. The gap to the unloaded pages remains, but a
// gap is recoverable by paging — a dropped message is not.
const protectedEarly = {
id: "protected-early",
type: "human",
content: "写一个算法PDF",
} as Message;
const latestHuman = {
id: "latest-human",
type: "human",
content: "写一本超级小说",
} as Message;
const latestAi = {
id: "latest-ai",
type: "ai",
content: "latest answer",
} as Message;
expect(
mergeMessages([latestHuman, latestAi], [protectedEarly, latestHuman], []),
).toEqual([protectedEarly, latestHuman, latestAi]);
});
test("mergeMessages lets live thread messages replace overlapping history", () => {
const oldHuman = {
id: "human-1",
type: "human",
content: "old",
} as Message;
const liveHuman = {
id: "human-1",
type: "human",
content: "live",
} as Message;
const oldAi = {
id: "ai-1",
type: "ai",
content: "old",
} as Message;
const liveAi = {
id: "ai-1",
type: "ai",
content: "live",
} as Message;
expect(mergeMessages([oldHuman, oldAi], [liveHuman, liveAi], [])).toEqual([
liveHuman,
liveAi,
]);
});
test("mergeMessages preserves historical run metadata on a live checkpoint replacement", () => {
const persistedAi = {
id: "ai-1",
type: "ai",
content: "persisted",
additional_kwargs: { turn_duration: 114 },
} as Message;
const history = buildVisibleHistoryMessages(
[
{
run_id: "run-1",
seq: 1,
content: persistedAi,
metadata: { caller: "lead_agent" },
created_at: "2026-07-21T00:00:00Z",
},
],
new Set(),
);
const checkpointAi = {
id: "ai-1",
type: "ai",
content: "live checkpoint",
} as Message;
expect(mergeMessages(history, [checkpointAi], [])).toEqual([
{
...checkpointAi,
run_id: "run-1",
additional_kwargs: { turn_duration: 114 },
},
]);
});
test("mergeMessages keeps a protected pre-compression input at its canonical position", () => {
const canonicalInput = {
id: "input-1",
type: "human",
content: "写一个算法PDF",
} as Message;
const checkpointInput = {
id: "input-1",
type: "human",
content: [{ type: "text", text: "写一个算法PDF" }],
} as Message;
const clarificationCard = {
id: "clarification-card",
type: "tool",
tool_call_id: "clarification-call",
content: "Create a new PDF",
} as Message;
const directionAnswer = {
id: "input-3",
type: "human",
content: "二叉树相关的即可",
} as Message;
const canonicalRetainedTail = {
id: "retained-ai",
type: "ai",
content: "persisted tail",
} as Message;
const checkpointRetainedTail = {
id: "retained-ai",
type: "ai",
content: "live tail",
} as Message;
expect(
mergeMessages(
[
canonicalInput,
clarificationCard,
directionAnswer,
canonicalRetainedTail,
],
[checkpointInput, checkpointRetainedTail],
[],
),
).toEqual([
checkpointInput,
clarificationCard,
directionAnswer,
checkpointRetainedTail,
]);
});
test("mergeMessages keeps source order when history and live tail do not overlap", () => {
const historyAi = {
id: "history-ai",
type: "ai",
content: "persisted",
} as Message;
const liveHuman = {
id: "live-human",
type: "human",
content: "live",
} as Message;
expect(mergeMessages([historyAi], [liveHuman], [])).toEqual([
historyAi,
liveHuman,
]);
});
test("mergeMessages appends a trailing live-only segment after newer canonical rows", () => {
const message = (id: string) =>
({ id, type: "human", content: id }) as Message;
const [a, b, c, d, y] = ["a", "b", "c", "d", "y"].map(message) as [
Message,
Message,
Message,
Message,
Message,
];
expect(mergeMessages([a, b, c, d], [b, y], [])).toEqual([a, b, c, d, y]);
});
test("mergeMessages keeps live-only messages between shared anchors in place", () => {
const message = (id: string) =>
({ id, type: "human", content: id }) as Message;
const [a, b, c, d, x, y] = ["a", "b", "c", "d", "x", "y"].map(message) as [
Message,
Message,
Message,
Message,
Message,
Message,
];
expect(mergeMessages([a, b, c, d], [b, x, d, y], [])).toEqual([
a,
b,
c,
x,
d,
y,
]);
});
test("mergeMessages deduplicates tool messages by tool_call_id", () => {
const oldTool = {
id: "tool-message-old",
type: "tool",
tool_call_id: "call-1",
content: "old",
} as Message;
const liveTool = {
id: "tool-message-live",
type: "tool",
tool_call_id: "call-1",
content: "live",
} as Message;
expect(mergeMessages([oldTool], [liveTool], [])).toEqual([liveTool]);
});
test("mergeMessages keeps a visible history message when a hidden live message reuses its id", () => {
const historyHuman = {
id: "human-1",
type: "human",
content: "visible user prompt",
} as Message;
const hiddenReminder = {
id: "human-1",
type: "human",
content: "<system-reminder>hidden</system-reminder>",
additional_kwargs: { hide_from_ui: true },
} as Message;
const liveAi = {
id: "ai-1",
type: "ai",
content: "live answer",
} as Message;
expect(mergeMessages([historyHuman], [hiddenReminder, liveAi], [])).toEqual([
historyHuman,
liveAi,
]);
});
test("mergeMessages lets a visible live message replace overlapping hidden history", () => {
const hiddenHistoryHuman = {
id: "human-1",
type: "human",
content: "<system-reminder>hidden</system-reminder>",
additional_kwargs: { hide_from_ui: true },
} as Message;
const liveHuman = {
id: "human-1",
type: "human",
content: "visible user prompt",
} as Message;
expect(mergeMessages([hiddenHistoryHuman], [liveHuman], [])).toEqual([
liveHuman,
]);
});
test("getSummarizationMiddlewareMessages matches DeerFlow summarization update keys", () => {
const removeAll = {
id: "__remove_all__",
type: "remove",
content: "",
} as Message;
const summary = {
id: "summary-1",
type: "human",
name: "summary",
content: "summary",
} as Message;
expect(
getSummarizationMiddlewareMessages({
"DeerFlowSummarizationMiddleware.before_model": {
messages: [removeAll, summary],
},
}),
).toEqual([removeAll, summary]);
});
test("getSummarizationMiddlewareMessages matches base LangChain summarization update keys", () => {
const summary = {
id: "summary-1",
type: "human",
name: "summary",
content: "summary",
} as Message;
expect(
getSummarizationMiddlewareMessages({
"SummarizationMiddleware.before_model": {
messages: [summary],
},
}),
).toEqual([summary]);
});
test("getSummarizationMiddlewareMessages ignores unrelated suffix-sharing update keys", () => {
const summary = {
id: "summary-1",
type: "human",
name: "summary",
content: "summary",
} as Message;
expect(
getSummarizationMiddlewareMessages({
"OtherSummarizationMiddleware.before_model": {
messages: [summary],
},
}),
).toBeUndefined();
});
test("getVisibleOptimisticMessages hides optimistic user input after server human arrives", () => {
const optimisticHuman = {
id: "opt-human-1",
type: "human",
content: "hello",
} as Message;
expect(getVisibleOptimisticMessages([optimisticHuman], 0, 1)).toEqual([]);
});
test("mergeMessages shows server human instead of optimistic duplicate after first response", () => {
const serverHuman = {
id: "server-human-1",
type: "human",
content: "hello",
} as Message;
const optimisticHuman = {
id: "opt-human-1",
type: "human",
content: "hello",
} as Message;
const visibleOptimistic = getVisibleOptimisticMessages(
[optimisticHuman],
0,
1,
);
expect(mergeMessages([], [serverHuman], visibleOptimistic)).toEqual([
serverHuman,
]);
});
test("edit replay of the only turn hides the optimistic copy once the server human arrives", () => {
// The runtime re-keys the first user message of a thread, so the persisted
// replacement never matches the optimistic id and only the count can confirm
// it. Masking the superseded turn drops the live count to zero first.
const supersededHuman = {
id: "human-1__user",
type: "human",
content: "introduce Li Bai",
} as Message;
const optimisticHuman = {
id: "replacement-1",
type: "human",
content: "introduce Du Fu",
} as Message;
const serverHuman = {
id: "replacement-1__user",
type: "human",
content: "introduce Du Fu",
} as Message;
const baseline = countHumanMessagesExcludingSuperseded(
[supersededHuman],
["human-1__user", "ai-1"],
);
expect(baseline).toBe(0);
expect(getVisibleOptimisticMessages([optimisticHuman], baseline, 0)).toEqual([
optimisticHuman,
]);
expect(getVisibleOptimisticMessages([optimisticHuman], baseline, 1)).toEqual(
[],
);
expect(mergeMessages([], [serverHuman], [])).toEqual([serverHuman]);
});
test("countHumanMessagesExcludingSuperseded keeps turns the replay does not supersede", () => {
const keptHuman = { id: "human-1", type: "human", content: "one" } as Message;
const supersededHuman = {
id: "human-2",
type: "human",
content: "two",
} as Message;
const ai = { id: "ai-1", type: "ai", content: "answer" } as Message;
expect(
countHumanMessagesExcludingSuperseded(
[keptHuman, ai, supersededHuman],
["human-2", "ai-2"],
),
).toBe(1);
});
test("getVisibleOptimisticMessages keeps optimistic user input until server human arrives", () => {
const optimisticHuman = {
id: "opt-human-1",
type: "human",
content: "hello",
} as Message;
expect(getVisibleOptimisticMessages([optimisticHuman], 0, 0)).toEqual([
optimisticHuman,
]);
});
test("getVisibleOptimisticMessages keeps non-human optimistic status messages", () => {
const optimisticAi = {
id: "opt-ai-1",
type: "ai",
content: "Uploading files...",
} as Message;
expect(getVisibleOptimisticMessages([optimisticAi], 0, 1)).toEqual([
optimisticAi,
]);
});
test("getVisibleOptimisticMessages hides the upload optimistic pair after server human arrives", () => {
const optimisticHuman = {
id: "opt-human-1",
type: "human",
content: "upload this",
} as Message;
const optimisticUploadingAi = {
id: "opt-ai-uploading",
type: "ai",
content: "Uploading files...",
} as Message;
expect(
getVisibleOptimisticMessages(
[optimisticHuman, optimisticUploadingAi],
0,
1,
),
).toEqual([]);
});
test("getVisibleOptimisticMessages hides optimistic user input after later server turns", () => {
const optimisticHuman = {
id: "opt-human-2",
type: "human",
content: "follow up",
} as Message;
expect(getVisibleOptimisticMessages([optimisticHuman], 3, 4)).toEqual([]);
expect(getVisibleOptimisticMessages([optimisticHuman], 3, 3)).toEqual([
optimisticHuman,
]);
});
test("areOptimisticMessagesConfirmed returns true when server messages contain every optimistic id", () => {
const optimisticHuman = {
id: "replacement-human-1",
type: "human",
content: "edited question",
} as Message;
const serverHuman = {
id: "replacement-human-1",
type: "human",
content: "edited question",
} as Message;
const serverAi = {
id: "replacement-ai-1",
type: "ai",
content: "new answer",
} as Message;
expect(
areOptimisticMessagesConfirmed([optimisticHuman], [serverHuman, serverAi]),
).toBe(true);
});
test("areOptimisticMessagesConfirmed ignores optimistic messages without stable ids", () => {
const optimisticHuman = {
type: "human",
content: "edited question",
} as Message;
expect(areOptimisticMessagesConfirmed([optimisticHuman], [])).toBe(false);
});
test("buildThreadMessagesPageUrl encodes the thread and backward cursor", () => {
expect(
buildThreadMessagesPageUrl(
"https://api.example.test/",
"thread/with space",
18,
),
).toBe(
"https://api.example.test/api/threads/thread%2Fwith%20space/messages/page?before_seq=18",
);
});
test("buildThreadMessagesPageUrl omits before_seq for the latest page", () => {
expect(
buildThreadMessagesPageUrl("https://api.example.test", "thread-1"),
).toBe("https://api.example.test/api/threads/thread-1/messages/page");
});
test("buildThreadMessagesPageUrl returns a relative URL behind nginx", () => {
expect(buildThreadMessagesPageUrl("", "thread-1", 42)).toBe(
"/api/threads/thread-1/messages/page?before_seq=42",
);
});
test("parseThreadMessagesPageResponse accepts a valid history page", () => {
const response = {
data: [runMessage(1), runMessage(2)],
has_more: true,
next_before_seq: 1,
};
expect(parseThreadMessagesPageResponse(response)).toBe(response);
});
test.each([
["missing", undefined],
["non-numeric", "2"],
["fractional", 2.5],
["unsafe", Number.MAX_SAFE_INTEGER + 1],
])(
"parseThreadMessagesPageResponse rejects a %s row seq",
(_description, seq) => {
expect(() =>
parseThreadMessagesPageResponse({
data: [{ ...runMessage(1), seq }],
has_more: false,
next_before_seq: null,
}),
).toThrow("invalid seq");
},
);
test("parseThreadMessagesPageResponse rejects duplicate row seq values", () => {
expect(() =>
parseThreadMessagesPageResponse({
data: [runMessage(1), runMessage(1)],
has_more: false,
next_before_seq: null,
}),
).toThrow("duplicate seq");
});
test("parseThreadMessagesPageResponse rejects an invalid pagination cursor", () => {
expect(() =>
parseThreadMessagesPageResponse({
data: [runMessage(1)],
has_more: true,
next_before_seq: null,
}),
).toThrow("invalid next_before_seq");
});
test("flattenThreadHistoryPages prepends backward pages in global seq order", () => {
expect(
flattenThreadHistoryPages([
{
data: [runMessage(5), runMessage(6)],
has_more: true,
next_before_seq: 5,
},
{
data: [runMessage(3), runMessage(4)],
has_more: true,
next_before_seq: 3,
},
{
data: [runMessage(1), runMessage(2)],
has_more: false,
next_before_seq: null,
},
]).map((message) => message.seq),
).toEqual([1, 2, 3, 4, 5, 6]);
});
test("flattenThreadHistoryPages retains backward pages when the latest page refreshes", () => {
const olderPage = {
data: [runMessage(1), runMessage(2)],
has_more: false,
next_before_seq: null,
};
expect(
flattenThreadHistoryPages([
{
data: [runMessage(3), runMessage(4), runMessage(5)],
has_more: true,
next_before_seq: 3,
},
olderPage,
]).map((message) => message.seq),
).toEqual([1, 2, 3, 4, 5]);
});
test("reconcileThreadHistoryRows retains rows displaced from a moving latest page", () => {
const previousRows = Array.from({ length: 50 }, (_, index) =>
runMessage(index + 1),
);
const currentRows = Array.from({ length: 50 }, (_, index) =>
runMessage(index + 51),
);
expect(
reconcileThreadHistoryRows(previousRows, currentRows, false).map(
(message) => message.seq,
),
).toEqual(Array.from({ length: 100 }, (_, index) => index + 1));
});
test("reconcileThreadHistoryRows refreshes overlapping rows without moving them", () => {
const stale = runMessage(50);
const refreshed = {
...runMessage(50),
content: {
id: "message-50",
type: "ai",
content: "final response",
additional_kwargs: { turn_duration: 704 },
} as Message,
};
const reconciled = reconcileThreadHistoryRows(
[runMessage(49), stale],
[refreshed, runMessage(51)],
false,
);
expect(reconciled.map((message) => message.seq)).toEqual([49, 50, 51]);
expect(reconciled[1]).toBe(refreshed);
});
test("reconcileThreadHistoryRows trusts a complete snapshot and prunes missing rows", () => {
const currentRows = [runMessage(2), runMessage(3)];
expect(
reconcileThreadHistoryRows(
[runMessage(1), ...currentRows],
currentRows,
true,
),
).toEqual(currentRows);
});
test("reconcileThreadHistoryRows preserves existing history when a row seq is invalid", () => {
const previousRows = [runMessage(1), runMessage(2)];
const invalidRow = {
...runMessage(3),
seq: undefined,
} as unknown as RunMessage;
const consoleError = rs
.spyOn(console, "error")
.mockImplementation(() => undefined);
expect(reconcileThreadHistoryRows(previousRows, [invalidRow], false)).toBe(
previousRows,
);
expect(consoleError).toHaveBeenCalledOnce();
consoleError.mockRestore();
});
test("reconcileThreadHistoryRows keeps insertion order on an invalid initial snapshot", () => {
const first = {
...runMessage(1),
seq: undefined,
content: { id: "first", type: "human", content: "first" } as Message,
} as unknown as RunMessage;
const second = {
...runMessage(2),
seq: undefined,
content: { id: "second", type: "ai", content: "second" } as Message,
} as unknown as RunMessage;
const consoleError = rs
.spyOn(console, "error")
.mockImplementation(() => undefined);
const currentRows = [first, second];
expect(reconcileThreadHistoryRows([], currentRows, false)).toBe(currentRows);
expect(consoleError).toHaveBeenCalledOnce();
consoleError.mockRestore();
});
test("infinite history refetch recalculates older-page cursors from the refreshed newest page", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const queryKey = ["thread-messages", "thread-1"] as const;
const requestedCursors: Array<number | null> = [];
let availableSeqs = Array.from({ length: 9 }, (_, index) => index + 1);
const observer = new InfiniteQueryObserver(queryClient, {
queryKey,
initialPageParam: null as number | null,
queryFn: ({ pageParam }): ThreadMessagesPageResponse => {
requestedCursors.push(pageParam);
const eligible = availableSeqs.filter(
(seq) => pageParam === null || seq < pageParam,
);
const pageSeqs = eligible.slice(-3);
return {
data: pageSeqs.map(runMessage),
has_more: eligible.length > pageSeqs.length,
next_before_seq:
eligible.length > pageSeqs.length ? (pageSeqs[0] ?? null) : null,
};
},
getNextPageParam: getThreadHistoryNextPageParam,
});
const unsubscribe = observer.subscribe(() => undefined);
await observer.refetch();
await observer.fetchNextPage();
expect(requestedCursors).toEqual([null, 7]);
availableSeqs = Array.from({ length: 12 }, (_, index) => index + 1);
requestedCursors.length = 0;
await queryClient.invalidateQueries({ queryKey });
expect(requestedCursors).toEqual([null, 10]);
expect(
observer
.getCurrentResult()
.data?.pages.map((page) => page.data.map((message) => message.seq)),
).toEqual([
[10, 11, 12],
[7, 8, 9],
]);
expect(observer.getCurrentResult().data?.pageParams).toEqual([null, 10]);
unsubscribe();
queryClient.clear();
});
test("infinite history stops and warns when has_more has no cursor", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const requestedCursors: Array<number | null> = [];
const warnSpy = rs.spyOn(console, "warn").mockImplementation(() => ({}));
const observer = new InfiniteQueryObserver(queryClient, {
queryKey: ["thread-messages", "invalid-cursor"],
initialPageParam: null as number | null,
queryFn: ({ pageParam }): ThreadMessagesPageResponse => {
requestedCursors.push(pageParam);
return { data: [], has_more: true, next_before_seq: null };
},
getNextPageParam: getThreadHistoryNextPageParam,
});
const unsubscribe = observer.subscribe(() => undefined);
try {
await observer.refetch();
await observer.fetchNextPage();
expect(requestedCursors).toEqual([null]);
expect(observer.getCurrentResult().hasNextPage).toBe(false);
expect(warnSpy).toHaveBeenCalledWith(
"Thread history returned has_more without next_before_seq; pagination cannot continue.",
);
} finally {
unsubscribe();
warnSpy.mockRestore();
queryClient.clear();
}
});
test("removeSetItems removes pending superseded ids after submit failure", () => {
expect(
removeSetItems(new Set(["run-old", "run-other"]), ["run-old"]),
).toEqual(new Set(["run-other"]));
});
test("buildVisibleHistoryMessages filters superseded runs but keeps regenerated run", () => {
const oldHuman = {
id: "human-1",
type: "human",
content: "question",
} as Message;
const oldAi = {
id: "ai-old",
type: "ai",
content: "old answer",
} as Message;
const newHuman = {
id: "human-1",
type: "human",
content: "question",
} as Message;
const newAi = {
id: "ai-new",
type: "ai",
content: "new answer",
} as Message;
const rows: RunMessage[] = [
{
run_id: "run-old",
seq: 1,
content: oldHuman,
metadata: { caller: "lead_agent" },
created_at: "2026-06-18T00:00:00Z",
},
{
run_id: "run-old",
seq: 2,
content: oldAi,
metadata: { caller: "lead_agent" },
created_at: "2026-06-18T00:00:01Z",
},
{
run_id: "run-new",
seq: 3,
content: newHuman,
metadata: { caller: "lead_agent" },
created_at: "2026-06-18T00:00:02Z",
},
{
run_id: "run-new",
seq: 4,
content: newAi,
metadata: { caller: "lead_agent" },
created_at: "2026-06-18T00:00:03Z",
},
];
// run_id is carried onto each content message (#3779) so historical subtask
// cards can fetch their persisted step history on expand.
expect(buildVisibleHistoryMessages(rows, new Set(["run-old"]))).toEqual([
{ ...newHuman, run_id: "run-new", additional_kwargs: { deerflow_seq: 3 } },
{ ...newAi, run_id: "run-new", additional_kwargs: { deerflow_seq: 4 } },
]);
});
test("buildVisibleHistoryMessages attaches run_id to each content message (#3779)", () => {
const rows: RunMessage[] = [
{
run_id: "run-1",
seq: 1,
content: { id: "ai-1", type: "ai", content: "answer" } as Message,
metadata: { caller: "lead_agent" },
created_at: "2026-06-26T00:00:00Z",
},
];
const result = buildVisibleHistoryMessages(rows, new Set());
expect((result[0] as { run_id?: string }).run_id).toBe("run-1");
});
// Regression coverage for #3825: after context summarization the backend emits
// RemoveMessage(ALL) + summary + retained, and onUpdateEvent rescues the removed
// messages into a current-stream transient bridge. The bridge fills only the
// journal flush/refetch gap and never mutates canonical history pages.
const summarizationHuman1 = {
id: "human-1",
type: "human",
content: "round 1 question",
} as Message;
const summarizationAi1 = {
id: "ai-1",
type: "ai",
content: "round 1 answer",
} as Message;
const summarizationHuman2 = {
id: "human-2",
type: "human",
content: "round 2 question",
} as Message;
const summarizationAi2 = {
id: "ai-2",
type: "ai",
content: "round 2 answer (retained)",
} as Message;
const summarizationMovedMessages = [
summarizationHuman1,
summarizationAi1,
summarizationHuman2,
];
test("resolveTransientHistoryBridge keeps rescued messages while history state is stale", () => {
const staleHistory: Message[] = [];
expect(
resolveTransientHistoryBridge(staleHistory, summarizationMovedMessages),
).toEqual(summarizationMovedMessages);
});
test("resolveTransientHistoryBridge appends rescued messages after canonical history", () => {
const olderLoadedHuman = {
id: "older-human",
type: "human",
content: "older loaded turn",
} as Message;
expect(
resolveTransientHistoryBridge(
[olderLoadedHuman],
summarizationMovedMessages,
),
).toEqual([olderLoadedHuman, ...summarizationMovedMessages]);
});
test("resolveTransientHistoryBridge does not collapse an unloaded gap before its first canonical anchor", () => {
// Real regression shape from thread 4e81444d-c6ce-471e-93fd-b6ddb18dc938:
// the default history page starts at event seq=35, while the clarification
// conversation lives at seq=2..14. Context compression captured both the
// old turns and a later message that overlaps the canonical page. The old
// turns must stay suppressed until their canonical page loads; otherwise
// the unloaded seq=15..34 gap is visually collapsed before the page anchor.
const clarificationRequest = {
id: "clarification-request",
type: "ai",
content: "Which PDF should I create?",
} as Message;
const clarificationCard = {
id: "clarification-card",
tool_call_id: "clarification-call",
type: "tool",
content: "Create a new algorithm PDF",
} as Message;
const clarificationAnswer = {
id: "clarification-answer",
type: "human",
content: "Create a new algorithm PDF",
} as Message;
const directionQuestion = {
id: "direction-question",
type: "ai",
content: "Which topic?",
} as Message;
const directionAnswer = {
id: "direction-answer",
type: "human",
content: "Binary trees",
} as Message;
const pageAnchor = {
id: "event-seq-35",
type: "tool",
tool_call_id: "event-seq-35-call",
content: "first message on the latest history page",
} as Message;
const latestAnswer = {
id: "event-seq-88",
type: "ai",
content: "latest answer",
} as Message;
const captured = [
summarizationHuman1,
clarificationRequest,
clarificationCard,
clarificationAnswer,
directionQuestion,
directionAnswer,
pageAnchor,
];
const canonical = [pageAnchor, latestAnswer];
const missingAfterCanonicalRefetch = pruneConfirmedTransientMessages(
captured,
canonical,
);
const bridgeOrder = mergeTransientHistoryBridgeOrder([], captured);
expect(
resolveTransientHistoryBridge(
canonical,
missingAfterCanonicalRefetch,
bridgeOrder,
).map((message) => message.id),
).toEqual(["event-seq-35", "event-seq-88"]);
});
test("resolveTransientHistoryBridge restores a prefix that was already rendered", () => {
const protectedPrompt = {
id: "protected-prompt",
type: "human",
content: "/ppt-master 帮我做个ppt",
} as Message;
const intermediateReply = {
id: "intermediate-reply",
type: "ai",
content: "正在生成页面",
} as Message;
const pageAnchor = {
id: "page-anchor",
type: "ai",
content: "继续导出 SVG",
} as Message;
const latestAnswer = {
id: "latest-answer",
type: "ai",
content: "任务仍在执行",
} as Message;
const captured = [protectedPrompt, intermediateReply, pageAnchor];
const bridgeOrder = mergeTransientHistoryBridgeOrder([], captured);
const previouslyRenderedOrder = [
protectedPrompt,
intermediateReply,
pageAnchor,
latestAnswer,
]
.map((message) => `message:${message.id}`)
.filter((identity): identity is string => Boolean(identity));
expect(
resolveTransientHistoryBridge(
[pageAnchor, latestAnswer],
captured,
bridgeOrder,
previouslyRenderedOrder,
).map((message) => message.id),
).toEqual([
"protected-prompt",
"intermediate-reply",
"page-anchor",
"latest-answer",
]);
});
test("resolveTransientHistoryBridge does not duplicate once canonical history catches up", () => {
expect(
resolveTransientHistoryBridge(
summarizationMovedMessages,
summarizationMovedMessages,
),
).toEqual(summarizationMovedMessages);
});
test("resolveTransientHistoryBridge returns history unchanged when the bridge is empty", () => {
const history = [summarizationHuman1, summarizationAi1];
expect(resolveTransientHistoryBridge(history, [])).toBe(history);
});
test("resolveThreadTransientHistoryBridge never leaks a bridge across threads", () => {
const canonical = [
{ id: "older-human", type: "human", content: "older" } as Message,
];
expect(
resolveThreadTransientHistoryBridge(
canonical,
summarizationMovedMessages,
"thread-a",
"thread-b",
),
).toBe(canonical);
expect(
resolveThreadTransientHistoryBridge(
canonical,
summarizationMovedMessages,
null,
null,
),
).toBe(canonical);
expect(
resolveThreadTransientHistoryBridge(
canonical,
summarizationMovedMessages,
"thread-a",
"thread-a",
),
).toEqual([canonical[0], ...summarizationMovedMessages]);
});
test("mergeTransientHistoryBridge preserves chronology across repeated compression", () => {
const human3 = {
id: "human-3",
type: "human",
content: "round 3 question",
} as Message;
const firstBridge = mergeTransientHistoryBridge(
[],
[summarizationHuman1, summarizationAi1],
);
const secondBridge = mergeTransientHistoryBridge(firstBridge, [
summarizationAi1,
summarizationHuman2,
human3,
]);
expect(secondBridge.map((message) => message.id)).toEqual([
"human-1",
"ai-1",
"human-2",
"human-3",
]);
});
test("mergeTransientHistoryBridge does not move a protected input recaptured by later compression", () => {
const protectedInput = {
id: "protected-input",
type: "human",
content: "写一个算法PDF",
} as Message;
const clarification = {
id: "clarification",
type: "ai",
content: "Which kind?",
} as Message;
const laterTail = {
id: "later-tail",
type: "ai",
content: "Working on the PDF",
} as Message;
const firstBridge = mergeTransientHistoryBridge(
[],
[protectedInput, clarification],
);
const secondBridge = mergeTransientHistoryBridge(firstBridge, [
{ ...protectedInput, content: [{ type: "text", text: "写一个算法PDF" }] },
laterTail,
]);
expect(secondBridge.map((message) => message.id)).toEqual([
"protected-input",
"clarification",
"later-tail",
]);
expect(secondBridge[0]?.content).toEqual([
{ type: "text", text: "写一个算法PDF" },
]);
});
test("mergeTransientHistoryBridgeOrder retains confirmed overlap as a non-rendering anchor", () => {
const firstOrder = mergeTransientHistoryBridgeOrder(
[],
[summarizationHuman1, summarizationAi1, summarizationHuman2],
);
const secondOrder = mergeTransientHistoryBridgeOrder(firstOrder, [
summarizationHuman2,
summarizationAi2,
]);
expect(secondOrder).toEqual([
"message:human-1",
"message:ai-1",
"message:human-2",
"message:ai-2",
]);
});
test("mergeTransientHistoryBridgeOrder returns the same array when nothing is new", () => {
const order = mergeTransientHistoryBridgeOrder(
[],
[summarizationHuman1, summarizationAi1],
);
// Identity, not just equality: this runs per render while the bridge is
// active and feeds the coalesced render memo (#4409 Phase 1).
expect(mergeTransientHistoryBridgeOrder(order, [summarizationAi1])).toBe(
order,
);
expect(
mergeTransientHistoryBridgeOrder(order, [
summarizationHuman1,
summarizationAi1,
]),
).toBe(order);
expect(mergeTransientHistoryBridgeOrder(order, [])).toBe(order);
expect(
mergeTransientHistoryBridgeOrder(order, [summarizationHuman2]),
).not.toBe(order);
});
test("mergeTransientHistoryBridgeOrder keeps a recaptured protected prefix in place", () => {
const protectedInput = {
id: "protected-input",
type: "human",
content: "first",
} as Message;
const oldTail = {
id: "old-tail",
type: "ai",
content: "old",
} as Message;
const newTail = {
id: "new-tail",
type: "ai",
content: "new",
} as Message;
const firstOrder = mergeTransientHistoryBridgeOrder(
[],
[protectedInput, oldTail],
);
const secondOrder = mergeTransientHistoryBridgeOrder(firstOrder, [
protectedInput,
newTail,
]);
expect(secondOrder).toEqual([
"message:protected-input",
"message:old-tail",
"message:new-tail",
]);
});
test("merge keeps the full conversation across summarization even when visibleHistory lags (regression for #3825)", () => {
// Hidden summary (name === "summary") + the retained latest answer is all the
// live thread carries after RemoveMessage(ALL).
const hiddenSummary = {
id: "summary-1",
type: "human",
name: "summary",
content: "conversation summary",
} as Message;
const postSummaryThread = [hiddenSummary, summarizationAi2];
// The bad render: visibleHistory is still empty, so without the buffer the
// rescued round-1/2 messages exist in neither merge input and are lost.
const effectiveHistory = resolveTransientHistoryBridge(
[],
summarizationMovedMessages,
);
const merged = mergeMessages(effectiveHistory, postSummaryThread, []);
expect(merged.map((m) => m.id)).toEqual([
"human-1",
"ai-1",
"human-2",
"summary-1",
"ai-2",
]);
});
test("pruneConfirmedTransientMessages drops canonical identities but keeps the rest", () => {
// History has caught up on the first two rescued messages only.
expect(
pruneConfirmedTransientMessages(summarizationMovedMessages, [
summarizationHuman1,
summarizationAi1,
]),
).toEqual([summarizationHuman2]);
});
test("pruneConfirmedTransientMessages keeps entries while canonical history is stale", () => {
expect(
pruneConfirmedTransientMessages(summarizationMovedMessages, []),
).toEqual(summarizationMovedMessages);
});
test("resolveTransientHistoryBridge prefers canonical copy over stale transient copy", () => {
// Same identity, but the buffered copy is an older snapshot. The live history
// copy (e.g. the finalized answer) must win — the buffer only fills gaps, it
// must never overwrite a message history already shows.
const staleBuffered = {
id: "ai-1",
type: "ai",
content: "streaming partial",
} as Message;
const liveFinal = {
id: "ai-1",
type: "ai",
content: "finalized answer",
} as Message;
expect(resolveTransientHistoryBridge([liveFinal], [staleBuffered])).toEqual([
liveFinal,
]);
});
test("computeSummarizationTransientMessages captures live turns dropped before the retained boundary", () => {
const removeAll = {
id: "__remove_all__",
type: "remove",
content: "",
} as Message;
const hiddenSummary = {
id: "summary-1",
type: "human",
name: "summary",
content: "conversation summary",
} as Message;
const liveThreadBeforeSummary = [
summarizationHuman1,
summarizationAi1,
summarizationHuman2,
summarizationAi2,
];
// Summarization emits RemoveMessage(ALL) + hidden summary + retained answer.
const summarizationMessages = [removeAll, hiddenSummary, summarizationAi2];
expect(
computeSummarizationTransientMessages(
liveThreadBeforeSummary,
summarizationMessages,
new Set([hiddenSummary.id!]),
),
).toEqual([summarizationHuman1, summarizationAi1, summarizationHuman2]);
});
test("computeSummarizationTransientMessages rescues rendered processing steps missing from a stale live snapshot", () => {
const processingMessages = Array.from({ length: 10 }, (_, index) => {
const step = index + 1;
return {
id: `processing-${step}`,
type: step % 2 === 0 ? "tool" : "ai",
...(step % 2 === 0 ? { tool_call_id: `call-${step - 1}` } : {}),
content: `step ${step}`,
} as Message;
});
// The SDK has already advanced to the 10-message post-compaction window,
// while the UI's previous committed frame still contains steps 1..10.
const staleLiveSnapshot = processingMessages.slice(4);
const summarizationMessages = [
{
id: "__remove_all__",
type: "remove",
content: "",
} as Message,
...staleLiveSnapshot,
];
expect(
computeSummarizationTransientMessages(
staleLiveSnapshot,
summarizationMessages,
new Set(),
processingMessages,
),
).toEqual(processingMessages.slice(0, 4));
});
test("computeSummarizationTransientMessages rescues steps between a protected input and retained tail", () => {
const protectedInput = {
id: "protected-input",
type: "human",
content: "Run a long sequential research task",
} as Message;
const removedSteps = Array.from({ length: 4 }, (_, index) => ({
id: `protected-window-step-${index + 1}`,
type: "ai",
content: `completed ${index + 1}`,
})) as Message[];
const retainedTail = {
id: "retained-tail",
type: "tool",
tool_call_id: "retained-call",
content: "latest search result",
} as Message;
const renderedMessages = [protectedInput, ...removedSteps, retainedTail];
const retainedWindow = [protectedInput, retainedTail];
expect(
computeSummarizationTransientMessages(
retainedWindow,
[
{
id: "__remove_all__",
type: "remove",
content: "",
} as Message,
...retainedWindow,
],
new Set(),
renderedMessages,
),
).toEqual(removedSteps);
});
test("repeated compaction keeps every previously rendered processing step in order", () => {
const processingMessages = Array.from({ length: 12 }, (_, index) => {
const step = index + 1;
return {
id: `repeat-processing-${step}`,
type: step % 2 === 0 ? "tool" : "ai",
...(step % 2 === 0 ? { tool_call_id: `repeat-call-${step - 1}` } : {}),
content: `step ${step}`,
} as Message;
});
const removeAll = {
id: "__remove_all__",
type: "remove",
content: "",
} as Message;
const firstTail = processingMessages.slice(4, 10);
const firstMoved = computeSummarizationTransientMessages(
firstTail,
[removeAll, ...firstTail],
new Set(),
processingMessages.slice(0, 10),
);
const firstBridge = mergeTransientHistoryBridge([], firstMoved);
const firstMerged = mergeMessages(firstBridge, firstTail, []);
const secondTail = processingMessages.slice(6);
const secondMoved = computeSummarizationTransientMessages(
secondTail,
[removeAll, ...secondTail],
new Set(),
processingMessages,
);
const secondBridge = mergeTransientHistoryBridge(firstBridge, secondMoved);
const secondMerged = mergeMessages(secondBridge, secondTail, []);
expect(firstMerged.map((message) => message.id)).toEqual(
processingMessages.slice(0, 10).map((message) => message.id),
);
expect(secondMerged.map((message) => message.id)).toEqual(
processingMessages.map((message) => message.id),
);
});
test("rendered message ledger survives rolling live windows before repeated compaction", () => {
const processingMessages = Array.from({ length: 23 }, (_, index) => {
const step = index + 1;
return {
id: `rolling-processing-${step}`,
type: "ai",
content: `completed ${step}/26`,
} as Message;
});
const firstVisibleWindow = processingMessages.slice(0, 13);
const secondVisibleWindow = processingMessages.slice(8, 18);
const thirdVisibleWindow = processingMessages.slice(13);
const firstLedger = mergeRenderedMessageLedger([], firstVisibleWindow);
const secondLedger = mergeRenderedMessageLedger(
firstLedger,
secondVisibleWindow,
);
const thirdLedger = mergeRenderedMessageLedger(
secondLedger,
thirdVisibleWindow,
);
expect(thirdLedger.map((message) => message.id)).toEqual(
processingMessages.map((message) => message.id),
);
// A later compaction retains only 19..23. The accumulated display ledger
// must still supply 14..18 (and every older displayed step) to the bridge.
const retainedTail = processingMessages.slice(18);
const moved = computeSummarizationTransientMessages(
retainedTail,
[
{
id: "__remove_all__",
type: "remove",
content: "",
} as Message,
...retainedTail,
],
new Set(),
thirdLedger,
);
expect(moved.map((message) => message.id)).toEqual(
processingMessages.slice(0, 18).map((message) => message.id),
);
});
test("rendered message ledger replaces a submitted user message with its injected server copy", () => {
const submittedHuman = {
id: "request-1",
type: "human",
content: "Build a presentation",
} as Message;
const injectedSystemReminder = {
id: "request-1",
type: "system",
content: "<system-reminder>today</system-reminder>",
additional_kwargs: { hide_from_ui: true },
} as Message;
const injectedMemory = {
id: "request-1__memory",
type: "human",
content: "<memory>context</memory>",
additional_kwargs: { hide_from_ui: true },
} as Message;
const injectedHuman = {
id: "request-1__user",
type: "human",
content: "Build a presentation",
name: "user-input",
} as Message;
const assistantStep = {
id: "assistant-step-1",
type: "ai",
content: "Reading the presentation skill",
} as Message;
const firstLedger = mergeRenderedMessageLedger([], [submittedHuman]);
const nextFrame = mergeMessages(
[submittedHuman],
[injectedSystemReminder, injectedMemory, injectedHuman, assistantStep],
[],
).filter((message) => message.additional_kwargs?.hide_from_ui !== true);
const nextLedger = mergeRenderedMessageLedger(firstLedger, nextFrame);
expect(nextLedger).toEqual([injectedHuman, assistantStep]);
expect(nextLedger.filter((message) => message.type === "human")).toHaveLength(
1,
);
});
test("local turn order keeps early streamed steps behind the user message", () => {
const previousHuman = {
id: "previous-human",
type: "human",
content: "Previous request",
} as Message;
const previousAssistant = {
id: "previous-assistant",
type: "ai",
content: "Previous answer",
} as Message;
const earlyAssistantStep = {
id: "early-assistant-step",
type: "ai",
content: "Reading the presentation skill",
} as Message;
const optimisticHuman = {
id: "opt-human-current",
type: "human",
content: "Build a presentation",
} as Message;
const injectedHuman = {
id: "current-request__user",
type: "human",
content: "Build a presentation",
} as Message;
const injectedMemory = {
id: "current-request__memory",
type: "human",
content: "<memory>context</memory>",
additional_kwargs: { hide_from_ui: true },
} as Message;
const laterAssistantStep = {
id: "later-assistant-step",
type: "ai",
content: "Writing the presentation plan",
} as Message;
const baselineIdentities = new Set([
"message:previous-human",
"message:previous-assistant",
]);
expect(
restoreLocalTurnMessageOrder(
[previousHuman, previousAssistant, earlyAssistantStep, optimisticHuman],
baselineIdentities,
),
).toEqual([
previousHuman,
previousAssistant,
optimisticHuman,
earlyAssistantStep,
]);
expect(
restoreLocalTurnMessageOrder(
[
previousHuman,
previousAssistant,
earlyAssistantStep,
injectedMemory,
injectedHuman,
laterAssistantStep,
],
baselineIdentities,
),
).toEqual([
previousHuman,
previousAssistant,
injectedMemory,
injectedHuman,
earlyAssistantStep,
laterAssistantStep,
]);
});
test("reconnected turn order moves same-run steps back behind the user message", () => {
// Reload mid-run: replayed `messages-tuple` steps reach the merged list
// before the turn's human message (the retained replay buffer may have
// dropped it). The live-only human is woven before the next shared anchor,
// leaving same-run steps above the user message they belong to.
const stepA1 = {
id: "step-a1",
type: "ai",
content: "Searching the web",
tool_calls: [{ id: "tc-a1", name: "web_search", args: {} }],
run_id: "run-r",
} as unknown as Message;
const stepA2 = {
id: "step-a2",
type: "tool",
content: "search results",
tool_call_id: "tc-a2",
run_id: "run-r",
} as Message;
const human = {
id: "human-r",
type: "human",
content: "Analyze deerflow",
} as Message;
const stepB1 = {
id: "step-b1",
type: "ai",
content: "Reading the source",
run_id: "run-r",
} as Message;
expect(
restoreReconnectedTurnMessageOrder([stepA1, stepA2, human, stepB1]),
).toEqual([human, stepA1, stepA2, stepB1]);
});
test("reconnected turn order leaves a resent turn after an interrupted run untouched", () => {
// Legit layout: an interrupted earlier run left steps without a final
// answer, then the user sent a new message. The earlier run's steps must
// NOT be pulled below the new human message.
const interruptedStep = {
id: "step-old",
type: "ai",
content: "Interrupted run step",
run_id: "run-1",
} as Message;
const human = {
id: "human-2",
type: "human",
content: "Same question again",
run_id: "run-2",
} as Message;
const newStep = {
id: "step-new",
type: "ai",
content: "New run step",
run_id: "run-2",
} as Message;
expect(
restoreReconnectedTurnMessageOrder([interruptedStep, human, newStep]),
).toEqual([interruptedStep, human, newStep]);
});
test("reconnected turn order keeps interrupted uniform-run history in its original turn", () => {
// Branch-seeded and mocked feeds can stamp every message with one run_id.
// Without a terminal answer, that synthetic id cannot prove that the older
// tool step belongs to the newer human turn.
const human1 = {
id: "human-1",
type: "human",
content: "First question",
run_id: "run-x",
} as Message;
const interruptedStep = {
id: "step-1",
type: "ai",
content: "Working on the first question",
tool_calls: [{ id: "tc-1", name: "web_search", args: {} }],
run_id: "run-x",
} as unknown as Message;
const human2 = {
id: "human-2",
type: "human",
content: "Second question",
run_id: "run-x",
} as Message;
const step2 = {
id: "step-2",
type: "ai",
content: "Working on the second question",
run_id: "run-x",
} as Message;
expect(
restoreReconnectedTurnMessageOrder([
human1,
interruptedStep,
human2,
step2,
]),
).toEqual([human1, interruptedStep, human2, step2]);
});
test("reconnected turn order only moves steps of the sandwiched run in multi-turn history", () => {
const human1 = { id: "human-1", type: "human", content: "First" } as Message;
const step1 = {
id: "step-1",
type: "ai",
content: "First run step",
run_id: "run-1",
} as Message;
const answer1 = {
id: "answer-1",
type: "ai",
content: "First answer",
run_id: "run-1",
} as Message;
const misplacedStep = {
id: "step-misplaced",
type: "ai",
content: "Second run step above the human",
tool_calls: [{ id: "tc-misplaced", name: "read_file", args: {} }],
run_id: "run-2",
} as unknown as Message;
const human2 = {
id: "human-2",
type: "human",
content: "Second",
} as Message;
const step2 = {
id: "step-2",
type: "ai",
content: "Second run step below the human",
run_id: "run-2",
} as Message;
expect(
restoreReconnectedTurnMessageOrder([
human1,
step1,
answer1,
misplacedStep,
human2,
step2,
]),
).toEqual([human1, step1, answer1, human2, misplacedStep, step2]);
});
test("reconnected turn order moves live-only steps before any history loads", () => {
// Right after reconnect no history page has landed yet: every live message
// lacks run_id. A run_id-less step below the human proves the live stream
// is past turn start, so run_id-less steps above the human belong to the
// same reconnected run.
const earlyStep = {
id: "live-early",
type: "ai",
content: "Replayed step",
tool_calls: [{ id: "tc-early", name: "web_search", args: {} }],
} as unknown as Message;
const human = {
id: "live-human",
type: "human",
content: "Question",
} as Message;
const laterStep = {
id: "live-later",
type: "ai",
content: "Fresh step",
} as Message;
expect(
restoreReconnectedTurnMessageOrder([earlyStep, human, laterStep]),
).toEqual([human, earlyStep, laterStep]);
});
test("reconnected turn order keeps run_id-less steps when no run_id-less step follows the human", () => {
// A run_id-less step above the human is only attributable to the current
// run when the run_id-less live stream continues below the human. With
// every message below the human carrying a (different) run_id, the stray
// step may belong to an older turn and must stay put.
const human1 = { id: "human-1", type: "human", content: "First" } as Message;
const step1 = {
id: "step-1",
type: "ai",
content: "First run step",
run_id: "run-1",
} as Message;
const strayStep = {
id: "stray",
type: "ai",
content: "Ambiguous step",
} as Message;
const human2 = {
id: "human-2",
type: "human",
content: "Second",
run_id: "run-2",
} as Message;
const step2 = {
id: "step-2",
type: "ai",
content: "Second run step",
run_id: "run-2",
} as Message;
expect(
restoreReconnectedTurnMessageOrder([
human1,
step1,
strayStep,
human2,
step2,
]),
).toEqual([human1, step1, strayStep, human2, step2]);
});
test("reconnected turn order keeps hidden control messages and older-run orphans in place", () => {
const orphanStep = {
id: "orphan",
type: "ai",
content: "Pagination orphan from an older run",
tool_calls: [{ id: "tc-orphan", name: "web_search", args: {} }],
run_id: "run-0",
} as unknown as Message;
const hiddenControl = {
id: "control",
type: "human",
content: "<memory>context</memory>",
additional_kwargs: { hide_from_ui: true },
} as Message;
const misplacedStep = {
id: "misplaced",
type: "ai",
content: "Same-run step",
tool_calls: [{ id: "tc-misplaced", name: "read_file", args: {} }],
run_id: "run-r",
} as unknown as Message;
const human = {
id: "human-r",
type: "human",
content: "Question",
} as Message;
const laterStep = {
id: "later",
type: "ai",
content: "Later same-run step",
run_id: "run-r",
} as Message;
expect(
restoreReconnectedTurnMessageOrder([
orphanStep,
hiddenControl,
misplacedStep,
human,
laterStep,
]),
).toEqual([orphanStep, hiddenControl, human, misplacedStep, laterStep]);
});
test("reconnected turn order is a no-op when the human message already leads its turn", () => {
const human = {
id: "human-r",
type: "human",
content: "Question",
} as Message;
const step = {
id: "step",
type: "ai",
content: "Step",
run_id: "run-r",
} as Message;
expect(restoreReconnectedTurnMessageOrder([human, step])).toEqual([
human,
step,
]);
expect(restoreReconnectedTurnMessageOrder([step])).toEqual([step]);
expect(restoreReconnectedTurnMessageOrder([])).toEqual([]);
});
test("reconnected turn order keeps a completed turn's answer above the next human message", () => {
// Regression for the branch-thread e2e shape: history feeds where every
// message shares one run_id (branch-seeded threads, mocked feeds). A
// terminal answer completes its turn and must never be pulled below the
// next human message even though the naive same-run check would match.
const human1 = {
id: "human-1",
type: "human",
content: "First question",
run_id: "run-x",
} as Message;
const answer1 = {
id: "ai-1",
type: "ai",
content: "First answer",
run_id: "run-x",
} as Message;
const human2 = {
id: "human-2",
type: "human",
content: "Second question",
run_id: "run-x",
} as Message;
const intermediate = {
id: "ai-2",
type: "ai",
content: "Intermediate answer",
run_id: "run-x",
} as Message;
const toolCalling = {
id: "ai-3",
type: "ai",
content: "",
tool_calls: [{ id: "tc-1", name: "write_todos", args: {} }],
run_id: "run-x",
} as unknown as Message;
const toolResult = {
id: "tool-1",
type: "tool",
tool_call_id: "tc-1",
content: "Todos updated",
run_id: "run-x",
} as Message;
const final = {
id: "ai-4",
type: "ai",
content: "Final answer",
run_id: "run-x",
} as Message;
expect(
restoreReconnectedTurnMessageOrder([
human1,
answer1,
human2,
intermediate,
toolCalling,
toolResult,
final,
]),
).toEqual([
human1,
answer1,
human2,
intermediate,
toolCalling,
toolResult,
final,
]);
});
test("reconnected turn order treats a content-only streaming text as a boundary", () => {
// Accepted transient (#4304): a still-streaming text step looks like a
// terminal answer until its tool call arrives, so it stays above the human
// until canonical history heals the order. Tool-calling steps after the
// last such boundary are still restored.
const streamingText = {
id: "streaming-text",
type: "ai",
content: "Let me analyze this",
run_id: "run-r",
} as Message;
const toolStep = {
id: "tool-step",
type: "ai",
content: "",
tool_calls: [{ id: "tc-1", name: "read_file", args: {} }],
run_id: "run-r",
} as unknown as Message;
const human = {
id: "human-r",
type: "human",
content: "Question",
} as Message;
const laterStep = {
id: "later",
type: "ai",
content: "",
tool_calls: [{ id: "tc-2", name: "write_file", args: {} }],
run_id: "run-r",
} as unknown as Message;
expect(
restoreReconnectedTurnMessageOrder([
streamingText,
toolStep,
human,
laterStep,
]),
).toEqual([streamingText, human, toolStep, laterStep]);
});
test("rendered message ledger does not retain explicitly superseded messages", () => {
const retained = {
id: "retained-answer",
type: "ai",
content: "keep me",
} as Message;
const superseded = {
id: "superseded-answer",
type: "ai",
content: "replace me",
} as Message;
expect(
mergeRenderedMessageLedger(
[retained, superseded],
[retained],
new Set([superseded.id!]),
),
).toEqual([retained]);
});
test("computeSummarizationTransientMessages excludes already-summarized control messages", () => {
const priorSummary = {
id: "summary-0",
type: "human",
name: "summary",
content: "earlier summary",
} as Message;
const liveThreadBeforeSummary = [
priorSummary,
summarizationHuman1,
summarizationAi1,
summarizationAi2,
];
const summarizationMessages = [
{ id: "__remove_all__", type: "remove", content: "" } as Message,
{
id: "summary-1",
type: "human",
name: "summary",
content: "new summary",
} as Message,
summarizationAi2,
];
// priorSummary is in the summarized set, so it must not enter the bridge.
expect(
computeSummarizationTransientMessages(
liveThreadBeforeSummary,
summarizationMessages,
new Set([priorSummary.id!, "summary-1"]),
),
).toEqual([summarizationHuman1, summarizationAi1]);
});
test("full summarization rescue pipeline keeps the conversation when history state lags (regression for #3825)", () => {
// Exercises the whole rescue algorithm the hook runs: derive the moved
// messages, buffer them, then merge against the post-summary thread while the
// canonical run-event page is still stale (empty).
const removeAll = {
id: "__remove_all__",
type: "remove",
content: "",
} as Message;
const hiddenSummary = {
id: "summary-1",
type: "human",
name: "summary",
content: "conversation summary",
} as Message;
const liveThreadBeforeSummary = [
summarizationHuman1,
summarizationAi1,
summarizationHuman2,
summarizationAi2,
];
const summarizationMessages = [removeAll, hiddenSummary, summarizationAi2];
const moved = computeSummarizationTransientMessages(
liveThreadBeforeSummary,
summarizationMessages,
new Set([hiddenSummary.id!]),
);
const staleHistory: Message[] = [];
const postSummaryThread = [hiddenSummary, summarizationAi2];
const merged = mergeMessages(
resolveTransientHistoryBridge(staleHistory, moved),
postSummaryThread,
[],
);
expect(merged.map((m) => m.id)).toEqual([
"human-1",
"ai-1",
"human-2",
"summary-1",
"ai-2",
]);
});
test("refresh reconstructs the same 1-to-6 order from run events without a bridge", () => {
const canonical = Array.from({ length: 6 }, (_, index) => ({
id: `message-${index + 1}`,
type: index % 2 === 0 ? "human" : "ai",
content: String(index + 1),
})) as Message[];
const checkpointTail = canonical.slice(4);
expect(
mergeMessages(canonical, checkpointTail, []).map(
(message) => message.content,
),
).toEqual(["1", "2", "3", "4", "5", "6"]);
});
test("a compacted checkpoint's protected user message survives a history page window that misses it (#4666)", () => {
// Captured from a real two-round long run: once the thread passes the
// 50-row `/messages/page` window AND context compaction fires, the two
// sources stop overlapping at the head. History's first page starts
// mid-run, while the compacted checkpoint still carries the turn's first
// user message (summarization rescues the dynamic-context triplet).
//
// That message is the user's own question. Suppressing it because its
// canonical position sits in an unloaded page makes it vanish from the
// transcript entirely — the "用户消息消失" reports in #4666 / #4508 / #4363.
const reminder = {
id: "u1",
type: "system",
content: "<system-reminder><current_date>…</current_date>",
additional_kwargs: { hide_from_ui: true, dynamic_context_reminder: true },
} as unknown as Message;
const firstUserMessage = {
id: "u1__user",
type: "human",
content: "MARK-FIRST-QUESTION",
} as Message;
const recentStep = {
id: "step-40",
type: "ai",
content: "step 40",
} as Message;
const laterUserMessage = {
id: "u2",
type: "human",
content: "SECOND-QUESTION",
} as Message;
// First history page: starts mid-run, has_more=true — no first user message.
const canonicalWindow = [recentStep, laterUserMessage];
// Compacted checkpoint: reminder + rescued first user message + recent tail.
const compactedCheckpoint = [reminder, firstUserMessage, recentStep];
const merged = mergeMessages(canonicalWindow, compactedCheckpoint, []);
expect(merged.map((message) => message.content)).toContain(
"MARK-FIRST-QUESTION",
);
});
test("a checkpoint message earlier than the loaded window is placed by its seq (#4666)", () => {
// With `deerflow_seq` on both sides, placement stops being a guess. Captured
// shape: after compaction the checkpoint still holds the turn's first user
// message (seq=2) while the first history page starts at seq=29, so the only
// anchor available to the old rule sat 25 rows into the window.
const withSeq = (message: Message, seq: number) =>
({
...message,
additional_kwargs: { ...message.additional_kwargs, deerflow_seq: seq },
}) as Message;
const firstUserMessage = withSeq(
{
id: "u1__user",
type: "human",
content: "MARK-FIRST-QUESTION",
} as Message,
2,
);
const windowStep = withSeq(
{ id: "step-29", type: "ai", content: "…step 29" } as Message,
29,
);
const laterUserMessage = withSeq(
{ id: "u2", type: "human", content: "SECOND-QUESTION" } as Message,
41,
);
const anchorStep = withSeq(
{ id: "step-58", type: "ai", content: "…step 58" } as Message,
58,
);
// The anchor must sit INSIDE the window, as it does in the captured run
// (canonical #25 of 50): weaving before the anchor is what puts the message
// in the middle, and only seq can say it belongs at the head.
const canonicalWindow = [windowStep, laterUserMessage, anchorStep];
const compactedCheckpoint = [firstUserMessage, anchorStep];
expect(
mergeMessages(canonicalWindow, compactedCheckpoint, []).map(
(m) => m.content,
),
).toEqual(["MARK-FIRST-QUESTION", "…step 29", "SECOND-QUESTION", "…step 58"]);
});
test("buildVisibleHistoryMessages carries each row's seq onto the message", () => {
const rows = [
{
run_id: "run-1",
seq: 7,
content: { id: "m1", type: "human", content: "hi" } as Message,
metadata: { caller: "" },
created_at: "2026-08-04T00:00:00Z",
},
] as RunMessage[];
expect(
buildVisibleHistoryMessages(rows, new Set())[0]!.additional_kwargs
?.deerflow_seq,
).toBe(7);
});
test("a checkpoint message earlier than the loaded window is placed by its seq even when the two sides share no anchor (#4666)", () => {
// What a user hits by opening an old, already-summarized conversation and
// sending a new message. The loaded page is the newest rows from BEFORE that
// turn; the compacted checkpoint holds only the rescued first user message
// plus steps of the new run, which are not in the feed yet. The two sides
// therefore share no identity at all and the anchor walk never runs — so the
// rescued turn was appended after the whole window (measured at row 50 of 50
// on a reproducing run) even though its seq was known the entire time.
const withSeq = (message: Message, seq: number) =>
({
...message,
additional_kwargs: { ...message.additional_kwargs, deerflow_seq: seq },
}) as Message;
const rescuedFirstTurn = withSeq(
{
id: "u1__user",
type: "human",
content: "MARK-FIRST-QUESTION",
} as Message,
2,
);
const loadedWindow = [
withSeq(
{ id: "step-172", type: "ai", content: "…step 172" } as Message,
172,
),
withSeq(
{ id: "step-174", type: "ai", content: "…step 174" } as Message,
174,
),
];
// Steps of the run the user just started: still streaming, so no seq yet, and
// no identity in common with the page on screen.
const newRunSteps = [
{ id: "step-new-1", type: "ai", content: "…new step 1" } as Message,
{ id: "step-new-2", type: "ai", content: "…new step 2" } as Message,
];
expect(
mergeMessages(loadedWindow, [rescuedFirstTurn, ...newRunSteps], []).map(
(m) => m.content,
),
).toEqual([
"MARK-FIRST-QUESTION",
"…step 172",
"…step 174",
"…new step 1",
"…new step 2",
]);
});