mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
* 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.
555 lines
18 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|