Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andClaude Opus 5.5 7dec67fbf9 fix(app): start a catch-up's follow-up page after rows applied meanwhile
A recovery paged forward from each page's end cursor without looking at
what the chat had applied since. When the latest-tail fallback brought
the chat to the end during a recovery, the recovery kept paging through
history it already held, one round trip per page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 00:05:12 +00:00
paseo-bot[bot]andClaude Opus 5.5 368bdcdb72 fix(app): let a chat without coverage fall back to the latest tail on gap recovery
A chat that was empty at its last catch-up now recovers a gap with an
`after` fetch from seq 0. When that recovery overflowed a page, it paged
forward through the whole history one page per round trip, because only
a resume was allowed to fall back to the latest tail. A chat without
coverage has nothing to keep, so it now falls back the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 23:49:54 +00:00
paseo-bot[bot]andOkan YILDIRIM c091ed2393 test(app): let the hidden-submission spec wait for its gap recovery
The spec hides the prompt's live row, so the reply's first live row is now a
gap that the chat recovers with one fetch after seq 0 while its workspace is
evicted. Take the request baseline after that recovery, so the assertion still
checks that returning to the workspace sends no fetch.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
2026-11-14 23:07:05 +00:00
Okan YILDIRIM 8afd2c014d test(app): end the reconnect spec's reply under test control
The spec's 80-word reply finished about eight seconds after it started.
A reconnect slower than the seven seconds left after `word10` meant no
live chunk could arrive, and the test stalled until its timeout even
though the app was correct.

The reply is now 2,000 words, longer than the test's timeout, so it is
still streaming however long the reconnect takes. The test stops the
turn once the live chunks have reached the chat, and compares the chat
against the reply as the daemon recorded it.
2026-11-13 21:33:17 +00:00
Okan YILDIRIM 5d34615436 fix(app): keep the prompt and whole reply when an empty chat reconnects mid-reply
A synced chat that was empty when it last caught up has no timeline range.
The sequencing gate accepted any live row there as the start of coverage,
so after a reconnect the first live chunk of a reply started a cursor
mid-reply. The resume page then looked already applied, the prompt and the
start of the reply were dropped, and the replica cache saved that range as
complete, so a reload showed the same partial chat.

Only an epoch's first row now starts coverage. A later row is a gap that
recovers after seq 0. A catch-up parked behind a running one starts after
the rows that one applied, so it does not replay them.

Bump the replica row store schema to 2. Both row stores clear their rows on
a version mismatch, so chats saved with a range that skips their prompt
reload their history from the host once.
2026-11-13 20:48:03 +00:00
11 changed files with 623 additions and 22 deletions
+11 -3
View File
@@ -49,9 +49,17 @@ Large unbounded timeline responses can exceed relay frame limits, so catch-up us
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When live delivery detects a sequence gap, the app fetches `direction: "after"`. If the daemon
responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap
recovery is complete only when `hasNewer: false`.
When live delivery detects a sequence gap, the app fetches `direction: "after"` its end cursor. If
the daemon responds with `hasNewer: true`, the app immediately fetches the next page from
`endCursor`. Gap recovery is complete only when `hasNewer: false`.
A chat that was empty when it last caught up has no end cursor to compare a live row against. Such a
row starts coverage only if it is the epoch's first row, seq 1. A later row means the chat missed the
rows before it, such as a prompt sent while the app was offline, so the app drops it and recovers
after seq 0. Coverage started at that later row would make the resume page look already applied, and
the chat and its replica cache would keep only the end of the reply. A recovery that waits behind a
running catch-up starts after whatever that catch-up applied, because the app applies a forward page
row by row and an older cursor repeats rows.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
@@ -527,7 +527,17 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
expect(gate.getClientRequestCount("agent.timeline.set_subscription.request")).toBe(
subscriptionRequests,
);
const requestsBeforeRelease = rememberTimelineRequestCounts(gate, target.agentId);
gate.releaseHeldAgentStreamEvent("turn_started");
// The chat never saw the hidden prompt's live row, so the reply's first row is a gap.
// The chat recovers the missing rows once, while its workspace is still evicted.
await expect
.poll(() => gate.getTimelineRequestCount("after", target.agentId))
.toBe(requestsBeforeRelease.after + 1);
await gate.waitForServerMessage(
"fetch_agent_timeline_response",
gate.getClientRequestCount("fetch_agent_timeline_request"),
);
const requestsBeforeReturn = rememberTimelineRequestCounts(gate, target.agentId);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
@@ -14,6 +14,8 @@ import {
answerFreshnessCheck,
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectEmptyChatSynced,
expectPromptAndWholeReply,
expectResumeAnsweredWithoutFetch,
expectResumeOverflowFallsBackToOneTail,
hideTabAndReturn,
@@ -22,7 +24,11 @@ import {
restoreUnchangedViewedTimeline,
restoreViewedTimelineHoldingCatchUp,
restoreViewedTimelineWithHeldResponse,
restoreViewedTimelineWithLiveRowsFirst,
reopenWithFreshnessCheckUnanswered,
seedStreamingReplyAgent,
sendPromptWhileDisconnected,
stopStreamingReply,
waitForResumeAnswer,
reopenAppWatchingForUpdatingToast,
watchForUpdatingToast,
@@ -147,4 +153,27 @@ test.describe("Agent timeline resume", () => {
await agent.cleanup();
}
});
test("an empty chat that reconnects mid-reply keeps the prompt and the whole reply", async ({
page,
}) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedStreamingReplyAgent();
try {
await openAgentTimeline(page, agent);
await expectEmptyChatSynced(page, gate);
await disconnectViewedTimeline(page, gate);
await sendPromptWhileDisconnected(agent);
await restoreViewedTimelineWithLiveRowsFirst(gate);
const reply = await stopStreamingReply(agent);
await expectPromptAndWholeReply(page, agent, reply);
await page.reload();
await expectPromptAndWholeReply(page, agent, reply);
} finally {
gate.restore();
await agent.cleanup();
}
});
});
@@ -1,5 +1,6 @@
import { expect, type Page } from "@playwright/test";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { readReplicaCache } from "./replica-cache-storage";
import type { LongTimelineAgent } from "./timeline-pagination";
import {
@@ -241,3 +242,100 @@ export async function restoreUnchangedViewedTimeline(
gate.restore();
await expectReconnectingToastGone(page);
}
export interface StreamingReplyAgent extends MockAgentWorkspace {
prompt: string;
}
/**
* A mock agent with no history whose reply streams one word every 100 ms. At 2,000 words the
* reply outlasts the test's timeout, so it is still streaming however long the app takes to
* reconnect. The test ends the turn with {@link stopStreamingReply}.
*/
export async function seedStreamingReplyAgent(): Promise<StreamingReplyAgent> {
const reply = Array.from({ length: 2_000 }, (_unused, index) => `word${index}`).join(" ");
const agent = await seedMockAgentWorkspace({
repoPrefix: "reply-reconnect-",
title: "Reply through reconnect",
featureValues: {
mockStreamingAssistantResponse: reply,
mockStreamingAssistantIntervalMs: 100,
},
});
return {
...agent,
prompt: "stream through a reconnect",
cleanup: async () => {
await agent.client.cancelAgent(agent.agentId).catch(() => undefined);
await agent.cleanup();
},
};
}
interface TimelineReader {
fetchAgentTimeline(
agentId: string,
request: { direction: "tail"; limit: number },
): Promise<{ entries: Array<{ item: { type: string; text?: string } }> }>;
}
async function readDaemonReply(agent: StreamingReplyAgent): Promise<string> {
const timeline = await (agent.client as unknown as TimelineReader).fetchAgentTimeline(
agent.agentId,
{ direction: "tail", limit: 0 },
);
return timeline.entries
.map(({ item }) => (item.type === "assistant_message" ? (item.text ?? "") : ""))
.join("");
}
/** Waits for the empty chat's first catch-up, so the chat is synced with no range. */
export async function expectEmptyChatSynced(page: Page, gate: DaemonWebSocketGate): Promise<void> {
await gate.waitForServerMessage(TIMELINE_RESPONSE);
await expect(page.getByTestId("agent-chat-scroll").filter({ visible: true })).toHaveCount(1, {
timeout: 30_000,
});
}
/** Another client sends the prompt, and the reply starts, while the chat is away. */
export async function sendPromptWhileDisconnected(agent: StreamingReplyAgent): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, agent.prompt);
await expect.poll(() => readDaemonReply(agent), { timeout: 30_000 }).toContain("word10");
}
/** Reconnects with live reply chunks reaching the chat before the catch-up page that holds them. */
export async function restoreViewedTimelineWithLiveRowsFirst(
gate: DaemonWebSocketGate,
): Promise<void> {
gate.holdNextClientRequest(TIMELINE_REQUEST);
const chunksBeforeReconnect = gate.getAgentStreamItemCount("assistant_message");
gate.restore();
await gate.waitForHeldClientRequest();
await gate.waitForAgentStreamItem("assistant_message", chunksBeforeReconnect + 5);
gate.releaseHeldClientRequest();
}
/** Stops the turn and returns the reply as the daemon recorded it. */
export async function stopStreamingReply(agent: StreamingReplyAgent): Promise<string> {
await agent.client.cancelAgent(agent.agentId);
await agent.client.waitForFinish(agent.agentId, 30_000);
const reply = (await readDaemonReply(agent)).trim();
expect(reply).toMatch(/^word0 word1 word2 .* word10 /);
return reply;
}
export async function expectPromptAndWholeReply(
page: Page,
agent: StreamingReplyAgent,
reply: string,
): Promise<void> {
const chat = page.getByTestId("agent-chat-scroll").filter({ visible: true }).first();
await expect(chat.getByText(agent.prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
await expect
.poll(
async () =>
(await chat.getByTestId("assistant-message").allInnerTexts()).map((text) => text.trim()),
{ timeout: 30_000 },
)
.toEqual([reply]);
}
@@ -1,3 +1,4 @@
export const REPLICA_ROW_STORE_SCHEMA_VERSION = 1;
// Version 1 could save a reconnected chat's timeline with a range that skips its prompt.
export const REPLICA_ROW_STORE_SCHEMA_VERSION = 2;
export const REPLICA_SINGLETON_ROW_ID = "singleton";
+315
View File
@@ -6,6 +6,7 @@ import {
type SqliteValue,
} from "@/runtime/replica-cache/row-store-sqlite";
import { afterEach, describe, expect, it } from "vitest";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { CachedTimeline } from "@/runtime/replica-cache";
import {
@@ -17,6 +18,8 @@ import {
} from "@/stores/session-store";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import type { StreamItem } from "@/types/stream";
import type { AgentStreamReducerEvent } from "./session-stream-reducers";
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
import { reduceTurnLiveness, type TurnLivenessTransition } from "./turn-liveness";
import {
createTimelineReplica,
@@ -1379,6 +1382,318 @@ describe("create handoff lifetime", () => {
});
});
describe("a chat that was empty when it went offline", () => {
const EPOCH = "epoch-1";
const PROMPT = "stream through a reconnect";
const THOUGHT = "Thinking before the reply.";
const TURN_ID = "turn-1";
const TIMESTAMP = "2026-10-31T10:00:00.000Z";
/** The daemon's timeline: one row per flushed chunk, read back as projected pages. */
class DaemonTimeline {
private readonly rows: Array<{ seq: number; item: AgentTimelineItem }> = [];
record(timelineItem: AgentTimelineItem): AgentStreamReducerEvent {
const seq = this.rows.length + 1;
this.rows.push({ seq, item: timelineItem });
return {
event: { type: "timeline", provider: "mock", turnId: TURN_ID, item: timelineItem },
seq,
epoch: EPOCH,
timestamp: new Date(TIMESTAMP),
};
}
page(request: ProjectedTimelineForwardFetchPlan): TimelineResponsePayload {
const maxSeq = this.rows.at(-1)?.seq ?? 0;
const after = request.direction === "after" ? request.cursor.seq : 0;
const entries = this.projected().filter((entry) => entry.seqEnd > after);
const first = entries[0];
const startSeq = request.direction === "after" ? after + 1 : first?.seqStart;
return {
requestId: `${request.direction}-${after}`,
agentId: AGENT_ID,
agent: null,
direction: request.direction,
projection: "projected",
reset: false,
epoch: EPOCH,
window: { minSeq: this.rows[0]?.seq ?? 0, maxSeq, nextSeq: maxSeq + 1 },
startCursor: first && startSeq !== undefined ? { epoch: EPOCH, seq: startSeq } : null,
endCursor: first ? { epoch: EPOCH, seq: maxSeq } : null,
entries,
error: null,
hasNewer: false,
hasOlder: after > 0,
staleCursor: false,
gap: false,
};
}
/** Contiguous reply and thought chunks read back as one row, like the daemon's projection. */
private projected(): TimelineResponsePayload["entries"] {
const entries: TimelineResponsePayload["entries"] = [];
for (const { seq, item: row } of this.rows) {
const previous = entries.at(-1);
const continues =
previous !== undefined &&
((row.type === "assistant_message" &&
previous.item.type === "assistant_message" &&
previous.item.messageId === row.messageId) ||
(row.type === "reasoning" && previous.item.type === "reasoning"));
if (
continues &&
(previous.item.type === "assistant_message" || previous.item.type === "reasoning") &&
(row.type === "assistant_message" || row.type === "reasoning")
) {
previous.item = { ...previous.item, text: `${previous.item.text}${row.text}` };
previous.seqEnd = seq;
previous.sourceSeqRanges = [{ startSeq: previous.seqStart, endSeq: seq }];
previous.collapsed = [
row.type === "assistant_message" ? "assistant_merge" : "reasoning_merge",
];
continue;
}
entries.push({
provider: "mock",
item: row,
timestamp: TIMESTAMP,
seqStart: seq,
seqEnd: seq,
sourceSeqRanges: [{ startSeq: seq, endSeq: seq }],
collapsed: [],
turnId: TURN_ID,
});
}
return entries;
}
}
interface PendingFetch {
request: ProjectedTimelineForwardFetchPlan;
/** Delivers a page the way the host runtime does: queued live chunks first, then the page. */
deliver(page: TimelineResponsePayload): void;
}
function openChat(storage: TimelineReplicaStorage) {
const fetches: PendingFetch[] = [];
const errors: unknown[] = [];
const fetch = (request: ProjectedTimelineForwardFetchPlan) =>
new Promise<{ hasNewer: boolean; endCursor: { epoch: string; seq: number } | null }>(
(resolve) => {
fetches.push({
request,
deliver: (page) => {
owner.flushStreamAgent(AGENT_ID);
owner.applyTimelineResponse(page);
resolve({ hasNewer: page.hasNewer, endCursor: page.endCursor });
},
});
},
);
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage,
prepareAgent: async () => undefined,
});
const owner = createViewedTimelineOwner({
serverId: SERVER_ID,
replica,
replaceDemandedAgentIds: () => undefined,
drainQueuedAgentMessage: () => undefined,
applyTurnLiveness: () => undefined,
ports: {
canCheckFreshness: () => true,
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
readCursor: (agentId) => {
const timeline = selectAgentTimelineState(
useSessionStore.getState().sessions[SERVER_ID],
agentId,
);
return timeline.status === "synced" && timeline.range
? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq }
: undefined;
},
fetchPage: (_agentId, request) => fetch(request),
fetchLatestTail: () =>
fetch({ direction: "tail", limit: 40, projection: "projected", wholeTurns: true }),
reportError: (error) => errors.push(error),
},
});
owner.setConnected(true);
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
return {
owner,
async nextFetch(): Promise<PendingFetch> {
await expect.poll(() => fetches.length).toBeGreaterThan(0);
return fetches.shift()!;
},
live(event: AgentStreamReducerEvent): void {
owner.applyStreamEvent(AGENT_ID, event);
owner.flushStreamAgent(AGENT_ID);
},
/** Answers every catch-up the chat still asks for until it reports itself current. */
async settle(daemon: DaemonTimeline): Promise<void> {
for (let turn = 0; turn < 20; turn += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
expect(errors).toEqual([]);
const next = fetches.shift();
if (next) next.deliver(daemon.page(next.request));
else if (owner.getAgentTimelineStatus(AGENT_ID) === "ready") return;
}
throw new Error("The chat never caught up");
},
};
}
function rows(): Array<{ kind: StreamItem["kind"]; text: string }> {
const session = useSessionStore.getState().sessions[SERVER_ID];
return [
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
].map((entry) => ({
kind: entry.kind,
text: "text" in entry && typeof entry.text === "string" ? entry.text : "",
}));
}
const reply = (words: number) =>
Array.from({ length: words }, (_unused, index) => `word${index}`).join(" ");
const chunk = (index: number) => `${index === 0 ? "" : " "}word${index}`;
const replyChunk = (index: number): AgentTimelineItem => ({
type: "assistant_message",
text: chunk(index),
messageId: "reply-1",
});
/**
* Opens the chat while its timeline is empty, then drops the connection while the prompt,
* an optional thought and the first three reply chunks are recorded.
*/
async function goOfflineWhileTheReplyStarts(input: {
reasoning: boolean;
storage: TimelineReplicaStorage;
}) {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const daemon = new DaemonTimeline();
const chat = openChat(input.storage);
const opened = await chat.nextFetch();
opened.deliver(daemon.page(opened.request));
expect(
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" });
chat.owner.setConnected(false);
daemon.record({ type: "user_message", text: PROMPT, messageId: "prompt-1" });
if (input.reasoning) daemon.record({ type: "reasoning", text: THOUGHT });
for (const index of [0, 1, 2]) daemon.record(replyChunk(index));
chat.owner.setConnected(true);
const catchUp = await chat.nextFetch();
return { daemon, chat, catchUp };
}
function expectedRows(reasoning: boolean, words: number) {
return [
{ kind: "user_message", text: PROMPT },
...(reasoning ? [{ kind: "thought", text: THOUGHT }] : []),
{ kind: "assistant_message", text: reply(words) },
];
}
/** A saved range that starts after the prompt claims history the chat never received. */
function expectCoverageFromTheFirstRow(commits: CachedTimeline[]): void {
expect(commits.map((commit) => commit.range?.startSeq ?? 1)).toEqual(commits.map(() => 1));
}
it.each([true, false])(
"keeps the prompt and the whole reply when live chunks arrive before a page that holds them (reasoning=%s)",
async (reasoning) => {
const commits: CachedTimeline[] = [];
const { daemon, chat, catchUp } = await goOfflineWhileTheReplyStarts({
reasoning,
storage: {
readTimeline: async () => undefined,
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
},
});
try {
chat.live(daemon.record(replyChunk(3)));
chat.live(daemon.record(replyChunk(4)));
catchUp.deliver(daemon.page(catchUp.request));
chat.live(daemon.record(replyChunk(5)));
await chat.settle(daemon);
expect(rows()).toEqual(expectedRows(reasoning, 6));
expectCoverageFromTheFirstRow(commits);
} finally {
chat.owner.dispose();
}
// The saved chat paints the same rows after a reload.
const saved = commits.at(-1);
useSessionStore.getState().clearSession(SERVER_ID);
useSessionStore.getState().initializeSession(SERVER_ID, null);
const reopened = createOwner({
readTimeline: async () => saved,
commitTimeline: () => undefined,
});
try {
reopened.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect.poll(rows).toEqual(expectedRows(reasoning, 6));
} finally {
reopened.dispose();
}
},
);
it("keeps the prompt when the page already holds a chunk the chat has not seen live", async () => {
const commits: CachedTimeline[] = [];
const { daemon, chat, catchUp } = await goOfflineWhileTheReplyStarts({
reasoning: true,
storage: {
readTimeline: async () => undefined,
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
},
});
try {
chat.live(daemon.record(replyChunk(3)));
const notYetLive = daemon.record(replyChunk(4));
catchUp.deliver(daemon.page(catchUp.request));
chat.live(notYetLive);
chat.live(daemon.record(replyChunk(5)));
await chat.settle(daemon);
expect(rows()).toEqual(expectedRows(true, 6));
expectCoverageFromTheFirstRow(commits);
} finally {
chat.owner.dispose();
}
});
it("finishes the reply when its last chunks arrive while an older page is in flight", async () => {
const commits: CachedTimeline[] = [];
const { daemon, chat, catchUp } = await goOfflineWhileTheReplyStarts({
reasoning: true,
storage: {
readTimeline: async () => undefined,
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
},
});
try {
const olderPage = daemon.page(catchUp.request);
chat.live(daemon.record(replyChunk(3)));
chat.live(daemon.record(replyChunk(4)));
catchUp.deliver(olderPage);
await chat.settle(daemon);
expect(rows()).toEqual(expectedRows(true, 5));
expectCoverageFromTheFirstRow(commits);
} finally {
chat.owner.dispose();
}
});
});
function createSqliteCache() {
const database = new DatabaseSync(":memory:");
let beforeRead = async () => {};
@@ -53,6 +53,7 @@ it.each([false, true])(
(hasAuthoritativeBaseline) => {
let head: StreamItem[] = [];
let tail: StreamItem[] = [];
let cursor: TimelineCursor | undefined;
for (const [index, turnId] of ["turn-1", "turn-2"].entries()) {
const result = processAgentStreamEvent({
event: {
@@ -65,12 +66,13 @@ it.each([false, true])(
epoch: "epoch-1",
currentTail: tail,
currentHead: head,
currentCursor: undefined,
currentCursor: cursor,
timestamp: new Date(1000),
hasAuthoritativeBaseline,
});
head = result.head;
tail = result.tail;
cursor = result.cursor ?? cursor;
}
expect([...tail, ...head]).toMatchObject([
{ kind: "thought", text: "turn-1", turnId: "turn-1" },
@@ -2848,7 +2850,7 @@ describe("processTimelineResponse", () => {
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentCursor: { epoch: "epoch-1", startSeq: 60, endSeq: 79 },
});
expect(getAssistantTexts([...live.tail, ...live.head])).toEqual([
"First paragraph.\n\nSecond paragraph.",
@@ -3677,6 +3679,7 @@ describe("processAgentStreamEvent", () => {
event: makeAssistantTimelineEvent("[System Error] failed"),
epoch: "timeline-1",
seq: 42,
currentCursor: { epoch: "timeline-1", startSeq: 1, endSeq: 41 },
});
expect(result.head).toEqual([
@@ -3897,6 +3900,24 @@ describe("processAgentStreamEvent", () => {
});
});
it("catches up from the first row when a chat that was empty receives a later row", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("word3"),
seq: 6,
epoch: "epoch-1",
currentCursor: undefined,
});
expect(result).toMatchObject({
head: [],
changedHead: false,
cursor: null,
cursorChanged: false,
sideEffects: [{ type: "catch_up", cursor: { epoch: "epoch-1", endSeq: 0 } }],
});
});
it("renders a live row over painted items without creating an authoritative cursor", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
@@ -4087,7 +4108,7 @@ describe("processAgentStreamEvents", () => {
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 237 },
});
expect(result.changedTail).toBe(false);
@@ -4261,6 +4282,7 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
timestamp: new Date("2026-05-02T10:00:00.186Z"),
});
@@ -4462,7 +4484,7 @@ describe("createAgentStreamReducerQueue", () => {
const scheduler = createManualScheduler();
let currentTail: StreamItem[] = [];
let currentHead: StreamItem[] = [];
let currentCursor: TimelineCursor | undefined;
let currentCursor: TimelineCursor | undefined = { epoch: "epoch-1", startSeq: 1, endSeq: 1 };
const queue = createAgentStreamReducerQueue({
getSnapshot: () => ({
@@ -4505,7 +4527,7 @@ describe("createAgentStreamReducerQueue", () => {
expect(getAssistantTexts(currentHead)).toEqual(["This is a paragraph continues."]);
expect(currentCursor).toEqual({
epoch: "epoch-1",
startSeq: 2,
startSeq: 1,
endSeq: 4,
});
});
@@ -289,7 +289,10 @@ function classifySessionTimelineSeq({
seq: number;
}): SessionTimelineSeqDecision {
if (!cursor) {
return "init";
// A synced chat without coverage was empty when it last caught up. Only an epoch's
// first row can start coverage; a later row means rows before it were missed, for
// example while offline, and a cursor started there would hide them from catch-up.
return seq === 1 ? "init" : "gap";
}
if (cursor.epoch !== epoch) {
return "drop_epoch";
@@ -1543,14 +1546,14 @@ function processTimelineSequencingGate(input: {
return {
...base,
shouldApplyStreamEvent: false,
sideEffects: currentCursor
? [
{
type: "catch_up",
cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq },
},
]
: [],
sideEffects: [
{
type: "catch_up",
cursor: currentCursor
? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq }
: { epoch, endSeq: 0 },
},
],
};
}
if (decision === "drop_epoch" && seq === 1) {
@@ -64,6 +64,21 @@ export function planTimelineResumeFetch(
: planTimelineTailFetch();
}
/**
* A catch-up that waited behind another starts after the rows applied meanwhile. A forward
* page is applied row by row, so asking again for rows the chat already holds repeats them.
*/
export function planTimelineCatchUpPastApplied(
request: ProjectedTimelineForwardFetchPlan,
applied: { epoch: string; endSeq: number } | undefined,
): ProjectedTimelineForwardFetchPlan {
if (request.direction !== "after" || !applied) return request;
if (applied.epoch !== request.cursor.epoch || applied.endSeq <= request.cursor.seq) {
return request;
}
return planTimelineCatchUpAfter({ epoch: applied.epoch, seq: applied.endSeq });
}
export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
return {
direction: "before",
@@ -441,6 +441,96 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("a gap parked behind a running tail resumes after the rows that tail applied", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
const tail = await world.nextFetch("agent-a");
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 0 });
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 7 });
tail.respond({ hasNewer: false, seq: 7 });
const recovery = await world.nextFetch("agent-a");
expect(recovery.request).toEqual({
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 7 },
limit: 40,
projection: "projected",
wholeTurns: true,
});
recovery.respond({ hasNewer: false, seq: 7 });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.dispose();
});
test("a chat without coverage that recovers a gap loads the latest tail when history overflows a page", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
const tail = await world.nextFetch("agent-a");
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 0 });
tail.respond({ hasNewer: false, seq: 0 });
const recovery = await world.nextFetch("agent-a");
expect(recovery.request).toEqual({
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 0 },
limit: 40,
projection: "projected",
wholeTurns: true,
});
recovery.respond({ hasNewer: true, seq: 40 });
const fallback = await world.nextFetch("agent-a");
expect(fallback.request).toEqual({
direction: "tail",
limit: 40,
projection: "projected",
wholeTurns: true,
});
fallback.respond({ hasNewer: false, seq: 90 });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.dispose();
});
test("a recovery's follow-up page starts after rows another fetch applied meanwhile", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-a")).respond({ hasNewer: false, seq: 0 });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 0 });
const firstPage = await world.nextFetch("agent-a");
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 40 });
firstPage.respond({ hasNewer: true, seq: 40 });
const latestTail = await world.nextFetch("agent-a");
expect(latestTail.request.direction).toBe("tail");
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 40 });
const recovery = await world.nextFetch("agent-a");
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 300 });
latestTail.respond({ hasNewer: false, seq: 300 });
recovery.respond({ hasNewer: true, seq: 80 });
const followUp = await world.nextFetch("agent-a");
expect(followUp.request).toEqual({
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 300 },
limit: 40,
projection: "projected",
wholeTurns: true,
});
followUp.respond({ hasNewer: false, seq: 300 });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.dispose();
});
test("reports a chat current only once its parked follow-up page completes", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
@@ -885,7 +975,8 @@ test("gap recovery supersedes completed catch-up and pages through the current t
const membership = await world.nextMembership();
membership.succeed();
const initial = await world.nextFetch("agent-a");
initial.respond({ hasNewer: false });
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
initial.respond({ hasNewer: false, seq: 10 });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
@@ -1,5 +1,6 @@
import {
planTimelineCatchUpAfter,
planTimelineCatchUpPastApplied,
planTimelineResumeFetch,
type ProjectedTimelineForwardFetchPlan,
} from "./timeline-sync-plan";
@@ -741,7 +742,10 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
await fetchUntilCurrent(
agentId,
generation,
planTimelineCatchUpAfter(page.endCursor),
planTimelineCatchUpPastApplied(
planTimelineCatchUpAfter(page.endCursor),
ports.readCursor(agentId),
),
false,
);
return;
@@ -819,7 +823,10 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
ensureCacheLoaded(agentId);
return;
}
const nextRequest = request ?? planTimelineResumeFetch(ports.readCursor(agentId));
const applied = ports.readCursor(agentId);
const nextRequest = request
? planTimelineCatchUpPastApplied(request, applied)
: planTimelineResumeFetch(applied);
const current = catchUps.get(agentId);
const decision = decideCatchUp({ current, request: nextRequest, supersede });
if (decision === "keep-and-park") {
@@ -833,11 +840,13 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
catchUpGenerations.set(agentId, generation);
catchUps.set(agentId, { generation, status: "running", request: nextRequest });
pendingCatchUps.delete(agentId);
// A gap recovery pages forward to keep the coverage it extends. Without coverage there is
// nothing to keep, so an overflowing page falls back to the latest tail, as a resume does.
void fetchUntilCurrent(
agentId,
generation,
nextRequest,
request === undefined && nextRequest.direction === "after",
nextRequest.direction === "after" && (request === undefined || applied === undefined),
);
};