Files
paseo/packages/app/src/timeline/replica.test.ts
T
Mohamed Boudra c46aee4e6a Revert #4421 and narrow the cache restore fix (#4436)
* Revert "Restore cached conversations before reconnecting (#4421)"

This reverts commit 1c51b52d07.

* fix(app): restore provider loading on native

* fix(app): preserve native navigators across appearance changes

Reapply the isolated appearance boundaries and native menu transform origin from #4421 so appearance hydration preserves native navigator and gesture host lifetimes.

* fix(app): retain cached baselines during live updates

Read the timeline concurrently with agent preparation and reconcile display-only cache rows beneath the live head using the existing reducer. Merge cached directory entries beneath live state while preserving the existing checkpoint race guard.
2026-09-07 19:18:54 +02:00

555 lines
18 KiB
TypeScript

import { DatabaseSync } from "node:sqlite";
import { ReplicaCache } from "@/runtime/replica-cache";
import {
createSqliteReplicaRowStore,
type ReplicaSqliteConnection,
type SqliteValue,
} from "@/runtime/replica-cache/row-store-sqlite";
import { afterEach, describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { CachedTimeline } from "@/runtime/replica-cache";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
import {
createTimelineReplica,
createViewedTimelineOwner,
type TimelineReplicaStorage,
type ViewedTimelineOwner,
} from "./viewed-timeline-sync";
const SERVER_ID = "timeline-replica-host";
const AGENT_ID = "agent-1";
function item(id: string, text: string, seq: number): StreamItem {
return {
kind: "assistant_message",
id,
text,
timestamp: new Date("2026-08-26T10:00:00.000Z"),
timelineCursor: { epoch: "epoch-1", seq },
};
}
function cachedTimeline(): CachedTimeline {
return {
agentId: AGENT_ID,
items: [item("cached", "cached", 4)],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
hasOlder: true,
};
}
function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage,
prepareAgent: async () => undefined,
});
return createViewedTimelineOwner({
serverId: SERVER_ID,
replica,
replaceDemandedAgentIds: () => undefined,
drainQueuedAgentMessage: () => undefined,
ports: {
initialDeliveryMode: "legacy",
setSubscription: async () => undefined,
readCursor: () => undefined,
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
reportError: () => undefined,
schedule: () => () => undefined,
},
});
}
function applySynced(agentId: string, seq: number): void {
useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, agentId, {
items: [item(`network-${agentId}`, "network", seq)],
head: [],
range: { epoch: "epoch-1", startSeq: 1, endSeq: seq },
older: "available",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
}
afterEach(() => useSessionStore.getState().clearSession(SERVER_ID));
describe("viewed timeline persistence", () => {
it("shares an in-flight cache preparation with the viewed owner", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
let release!: (value: CachedTimeline) => void;
let reads = 0;
const read = new Promise<CachedTimeline>((resolve) => {
release = resolve;
});
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage: {
readTimeline: () => {
reads += 1;
return read;
},
commitTimeline: () => undefined,
},
prepareAgent: async () => undefined,
});
const routePreparation = replica.prepare(AGENT_ID);
const ownerPreparation = replica.prepare(AGENT_ID);
release(cachedTimeline());
await Promise.all([routePreparation, ownerPreparation]);
expect(reads).toBe(1);
expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 });
});
it("paints cached history without claiming authoritative synchronization", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const owner = createOwner({
readTimeline: async () => cachedTimeline(),
commitTimeline: () => undefined,
});
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect
.poll(() =>
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
)
.toEqual({ status: "painted", items: cachedTimeline().items });
owner.dispose();
});
it("reconciles an overlapping projected message against its cached cursor", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const partial = "```mermaid\nflowchart LR\n Start --> Mid";
const complete = `${partial}dle\n Middle --> Done\n\`\`\``;
const owner = createOwner({
readTimeline: async () => ({
agentId: AGENT_ID,
items: [item("cached", partial, 4)],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
hasOlder: false,
}),
commitTimeline: () => undefined,
});
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect
.poll(() =>
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
)
.toMatchObject({ status: "painted" });
owner.applyTimelineResponse({
requestId: "page-after-cache",
agentId: AGENT_ID,
agent: null,
direction: "after",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
startCursor: { epoch: "epoch-1", seq: 5 },
endCursor: { epoch: "epoch-1", seq: 5 },
entries: [
{
provider: "mock",
item: { type: "assistant_message", text: complete },
timestamp: "2026-08-26T10:00:00.000Z",
seqStart: 2,
seqEnd: 5,
sourceSeqRanges: [{ startSeq: 2, endSeq: 5 }],
collapsed: ["assistant_merge"],
},
],
error: null,
hasNewer: false,
hasOlder: false,
staleCursor: false,
gap: false,
});
const session = useSessionStore.getState().sessions[SERVER_ID];
expect([
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
]).toMatchObject([{ kind: "assistant_message", text: complete }]);
owner.dispose();
});
it("reopens painted live mutations without persisting authoritative coverage", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
let durable: CachedTimeline | undefined = cachedTimeline();
let pending: CachedTimeline | undefined;
const storage: TimelineReplicaStorage = {
readTimeline: async () => durable,
commitTimeline: (_serverId, _agentId, timeline) => {
pending = timeline;
},
};
const first = createOwner(storage);
first.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect
.poll(() =>
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
)
.toMatchObject({ status: "painted" });
first.enqueueStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "live", messageId: "live" },
} as AgentStreamEventPayload,
seq: 5,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:01.000Z"),
});
first.flushStreamAgent(AGENT_ID);
expect(pending).toMatchObject({ range: null, hasOlder: false });
expect(pending?.items.map((entry) => entry.id)).toEqual(["cached", expect.any(String)]);
durable = pending;
first.dispose();
useSessionStore.getState().clearSession(SERVER_ID);
useSessionStore.getState().initializeSession(SERVER_ID, null);
const reopened = createOwner(storage);
reopened.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect
.poll(() =>
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
)
.toMatchObject({
status: "painted",
items: [
expect.objectContaining({ id: "cached" }),
expect.objectContaining({ text: "live" }),
],
});
reopened.dispose();
});
it("does not let a late cache read overwrite newer network state", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
let release!: (value: CachedTimeline) => void;
const read = new Promise<CachedTimeline>((resolve) => {
release = resolve;
});
const owner = createOwner({
readTimeline: () => read,
commitTimeline: () => undefined,
});
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
applySynced(AGENT_ID, 8);
release(cachedTimeline());
await expect
.poll(() =>
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
)
.toMatchObject({ status: "synced", range: { endSeq: 8 } });
owner.dispose();
});
it("paints cached rows without replacing a live head that arrives during preparation", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
let release!: (value: CachedTimeline) => void;
const read = new Promise<CachedTimeline>((resolve) => {
release = resolve;
});
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage: {
readTimeline: () => read,
commitTimeline: () => undefined,
},
prepareAgent: async () => undefined,
});
const preparation = replica.prepare(AGENT_ID);
useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, {
head: [item("live", "live", 5)],
});
release(cachedTimeline());
await preparation;
expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 });
expect(
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
).toEqual({ status: "painted", items: cachedTimeline().items });
expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID)).toEqual([
item("live", "live", 5),
]);
});
it("reconciles a live head that overlaps the cached canonical timeline", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
let release!: (value: CachedTimeline) => void;
const read = new Promise<CachedTimeline>((resolve) => {
release = resolve;
});
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage: {
readTimeline: () => read,
commitTimeline: () => undefined,
},
prepareAgent: async () => undefined,
});
const preparation = replica.prepare(AGENT_ID);
useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, {
head: [item("cached", "cached", 4), item("live", "live", 5)],
});
release(cachedTimeline());
await preparation;
const session = useSessionStore.getState().sessions[SERVER_ID];
expect([
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]);
expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 });
});
it("reconciles cached rows with a non-authoritative timeline painted during preparation", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
let release!: (value: CachedTimeline) => void;
const read = new Promise<CachedTimeline>((resolve) => {
release = resolve;
});
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage: {
readTimeline: () => read,
commitTimeline: () => undefined,
},
prepareAgent: async () => undefined,
});
const preparation = replica.prepare(AGENT_ID);
useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, {
items: [item("live", "live", 5)],
head: [],
range: null,
older: "none",
newer: false,
synchronized: false,
acknowledgedClientMessageIds: [],
});
release(cachedTimeline());
await preparation;
const session = useSessionStore.getState().sessions[SERVER_ID];
expect([
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]);
expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 });
});
it("persists accepted live stream commits through the owner", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
applySynced(AGENT_ID, 8);
const commits: CachedTimeline[] = [];
const owner = createOwner({
readTimeline: async () => undefined,
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
});
owner.enqueueStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "live", messageId: "live" },
} as AgentStreamEventPayload,
seq: 9,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:01.000Z"),
});
owner.flushStreamAgent(AGENT_ID);
expect(commits.at(-1)?.items.at(-1)).toMatchObject({ text: "live" });
expect(commits.at(-1)?.range?.endSeq).toBe(9);
owner.dispose();
});
it("applies and persists authoritative pages inside the owner", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const keys: string[] = [];
const owner = createOwner({
readTimeline: async () => undefined,
commitTimeline: (_serverId, agentId) => keys.push(agentId),
});
owner.applyTimelineResponse({
requestId: "page-1",
agentId: AGENT_ID,
agent: null,
direction: "tail",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 1, maxSeq: 0, nextSeq: 1 },
startCursor: null,
endCursor: null,
entries: [],
error: null,
hasNewer: false,
hasOlder: false,
staleCursor: false,
gap: false,
});
expect(
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
).toMatchObject({ status: "synced" });
expect(keys).toEqual([AGENT_ID]);
owner.dispose();
});
it("persists demanded agents independently", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const keys: string[] = [];
const owner = createOwner({
readTimeline: async () => undefined,
commitTimeline: (_serverId, agentId) => keys.push(agentId),
});
applySynced(AGENT_ID, 8);
applySynced("agent-2", 3);
for (const [agentId, seq] of [
[AGENT_ID, 9],
["agent-2", 4],
] as const) {
owner.enqueueStreamEvent(agentId, {
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: agentId, messageId: `live-${agentId}` },
} as AgentStreamEventPayload,
seq,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:01.000Z"),
});
owner.flushStreamAgent(agentId);
}
expect(keys).toEqual([AGENT_ID, "agent-2"]);
owner.dispose();
});
});
function createSqliteCache() {
const database = new DatabaseSync(":memory:");
let beforeRead = async () => {};
const connection: ReplicaSqliteConnection = {
async exec(sql) {
database.exec(sql);
},
async run(sql, params = []) {
database.prepare(sql).run(...params);
},
async all<Row>(sql: string, params: readonly SqliteValue[] = []) {
const rows = database.prepare(sql).all(...params) as Row[];
if (sql.includes("FROM rows") && sql.includes("WHERE")) await beforeRead();
return rows;
},
async transaction(operation) {
database.exec("BEGIN IMMEDIATE");
try {
await operation(connection);
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
},
};
const storage = createSqliteReplicaRowStore({ open: async () => connection }, 1);
const cache = new ReplicaCache(storage, { clearLegacyCache: async () => {} });
return {
cache,
database,
holdRead: (operation: () => Promise<void>) => {
beforeRead = operation;
},
};
}
describe("SQLite baseline restoration", () => {
it.each([false, true])(
"keeps display-only cached history under a live event (synced=%s)",
async (synced) => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const { cache, database, holdRead } = createSqliteCache();
cache.setHosts([SERVER_ID]);
cache.commitTimeline(SERVER_ID, AGENT_ID, {
...cachedTimeline(),
items: [item("earlier", "earlier", 1), ...cachedTimeline().items],
range: null,
});
await cache.flush();
holdRead(async () => {
if (synced) applySynced(AGENT_ID, 8);
else
useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, {
head: [item("cached", "cached", 4), item("live", "live", 5)],
});
});
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage: cache,
prepareAgent: async () => {},
});
await replica.prepare(AGENT_ID);
const session = useSessionStore.getState().sessions[SERVER_ID];
const timeline = selectAgentTimelineState(session, AGENT_ID);
expect(timeline.status).toBe(synced ? "synced" : "painted");
expect([
...(timeline.status === "cold" ? [] : timeline.items),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
]).toEqual(
synced
? [item(`network-${AGENT_ID}`, "network", 8)]
: [item("earlier", "earlier", 1), item("cached", "cached", 4), item("live", "live", 5)],
);
expect(replica.readCursor(AGENT_ID)).toBeUndefined();
database.close();
},
);
it("starts the timeline disk read while agent preparation is pending", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const { cache, database, holdRead } = createSqliteCache();
cache.setHosts([SERVER_ID]);
cache.commitTimeline(SERVER_ID, AGENT_ID, cachedTimeline());
await cache.flush();
let readStarted = false;
let release!: () => void;
const agentReady = new Promise<void>((resolve) => {
release = resolve;
});
holdRead(async () => {
readStarted = true;
});
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage: cache,
prepareAgent: () => agentReady,
});
const preparation = replica.prepare(AGENT_ID);
try {
await expect.poll(() => readStarted, { timeout: 500 }).toBe(true);
} finally {
release();
await preparation;
database.close();
}
});
});