mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 04:45:11 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dec67fbf9 | ||
|
|
368bdcdb72 | ||
|
|
c091ed2393 | ||
|
|
8afd2c014d | ||
|
|
5d34615436 |
+11
-3
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user