mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-17 01:56:18 +00:00
* 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>
2305 lines
64 KiB
TypeScript
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",
|
|
]);
|
|
});
|