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.
This commit is contained in:
Mohamed Boudra
2026-09-07 19:18:54 +02:00
committed by GitHub
parent 9c8d775895
commit c46aee4e6a
52 changed files with 3422 additions and 8638 deletions
@@ -37,7 +37,7 @@ import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-
import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import {
expectEmptyBaselineBootstrapsWithOneTail,
expectResumeOverflowFallsBackToOneTail,
rememberTimelineRequestCounts,
} from "../support/helpers/timeline-resume";
import {
@@ -484,8 +484,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
await expect(promptRow).toBeVisible();
await expect(response).toBeVisible();
await expectRenderedBefore(promptRow, response);
// The initial timeline was empty and live rows were suppressed: there is no resume cursor.
expectEmptyBaselineBootstrapsWithOneTail(gate, requestsBeforeReturn);
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
} finally {
gate.setAgentStreamSuppressed(false);
gate.restore();
@@ -94,18 +94,14 @@ interface PopoverFrameRecorderWindow extends Window {
__stopComposerAutocompleteFrameRecorder?: () => void;
}
async function expectAutocompleteCoveredAtPoint(page: Page, x: number, y: number): Promise<void> {
await expect
.poll(() =>
page.evaluate(
([pointX, pointY]) => {
const element = document.elementFromPoint(pointX, pointY);
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
},
[x, y],
),
)
.not.toBe("composer-autocomplete-popover");
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
return page.evaluate(
([pointX, pointY]) => {
const element = document.elementFromPoint(pointX, pointY);
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
},
[x, y],
);
}
async function installListCommandsStub(page: Page): Promise<void> {
@@ -680,11 +676,13 @@ test.describe("Composer autocomplete", () => {
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
await expectAutocompleteCoveredAtPoint(
const topTestId = await getTopTestIdAtPoint(
page,
popoverBox!.x + popoverBox!.width / 2,
popoverBox!.y + popoverBox!.height / 2,
);
expect(topTestId).not.toBe("composer-autocomplete-popover");
} finally {
await agent.cleanup();
}
@@ -1,4 +1,4 @@
import { test, expect, type Page } from "../support/fixtures";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import {
@@ -30,9 +30,8 @@ const PROMPT_PROFILE: TerminalProfile = {
args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"],
};
async function expectChatLaunchComplete(page: Page): Promise<void> {
const agentTab = page.getByTestId(/^workspace-tab-agent_/).filter({ visible: true });
await expect(agentTab).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
function hasLeftNewWorkspaceRoute(url: URL): boolean {
return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`);
}
test.describe("New workspace: launch target memory", () => {
@@ -79,8 +78,10 @@ test.describe("New workspace: launch target memory", () => {
await selectChatLaunch(page);
await expectChatLaunchSelected(page);
await submitNewWorkspacePrompt(page, "hello from chat");
// Leaving /new can expose a previous workspace before the submitted draft opens.
await expectChatLaunchComplete(page);
// The chat handoff navigates asynchronously (draft -> created agent). Let
// it fully settle before moving on, so it can't fire a late redirect
// that races the next step's navigation.
await page.waitForURL(hasLeftNewWorkspaceRoute, { timeout: 30_000 });
await openNewWorkspace();
await expectChatLaunchSelected(page);
@@ -96,9 +96,8 @@ test.describe("Model B sidebar shape", () => {
try {
// Open the workspace and materialize both an agent tab and a terminal tab.
await gotoWorkspace(page, mock.workspaceId);
await expect
.poll(() => getVisibleWorkspaceAgentTabIds(page))
.toContain(`workspace-tab-agent_${mock.agentId}`);
const agentTabs = await getVisibleWorkspaceAgentTabIds(page);
expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`);
await clickNewTerminal(page);
await expect(
@@ -494,19 +494,18 @@ export async function holdAgentOlderTimelinePages(
};
}
export async function delayAgentBootstrapTimelineResponse(
export async function delayAgentBootstrapTailResponse(
page: Page,
agentId: string,
): Promise<BootstrapTimelineGate> {
let bootstrapReleased = false;
let bootstrapResponseSeen = false;
let tailReleased = false;
let catchUpReleased = false;
const delayedBootstrapForwards: Array<() => void> = [];
const delayedTailForwards: Array<() => void> = [];
const delayedCatchUpForwards: Array<() => void> = [];
let resolveDelayedBootstrap: (() => void) | null = null;
let resolveDelayedTail: (() => void) | null = null;
let resolveDelayedCatchUp: (() => void) | null = null;
const delayedBootstrap = new Promise<void>((resolve) => {
resolveDelayedBootstrap = resolve;
const delayedTail = new Promise<void>((resolve) => {
resolveDelayedTail = resolve;
});
const delayedCatchUp = new Promise<void>((resolve) => {
resolveDelayedCatchUp = resolve;
@@ -520,16 +519,10 @@ export async function delayAgentBootstrapTimelineResponse(
const payload = sessionMessage ? getPayload(sessionMessage) : null;
const isTimelineResponse =
sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId;
// A certified replica bootstraps with after; an empty/display-only replica uses tail.
if (
isTimelineResponse &&
!bootstrapResponseSeen &&
(payload.direction === "tail" || payload.direction === "after")
) {
bootstrapResponseSeen = true;
resolveDelayedBootstrap?.();
if (bootstrapReleased) ws.send(message);
else delayedBootstrapForwards.push(() => ws.send(message));
if (isTimelineResponse && payload.direction === "tail") {
resolveDelayedTail?.();
if (tailReleased) ws.send(message);
else delayedTailForwards.push(() => ws.send(message));
return;
}
if (isTimelineResponse && payload.direction === "after") {
@@ -544,14 +537,14 @@ export async function delayAgentBootstrapTimelineResponse(
return {
release() {
bootstrapReleased = true;
for (const forward of delayedBootstrapForwards.splice(0)) forward();
tailReleased = true;
for (const forward of delayedTailForwards.splice(0)) forward();
},
releaseCatchUp() {
catchUpReleased = true;
for (const forward of delayedCatchUpForwards.splice(0)) forward();
},
waitForDelayedResponse: () => delayedBootstrap,
waitForDelayedResponse: () => delayedTail,
waitForDelayedCatchUp: () => delayedCatchUp,
};
}
@@ -2,7 +2,7 @@ import { expect, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { readReplicaCache } from "./replica-cache-storage";
import {
delayAgentBootstrapTimelineResponse,
delayAgentBootstrapTailResponse,
delayAgentOlderTimelineResponse,
holdDaemonHydration,
holdAgentOlderTimelinePages,
@@ -296,10 +296,9 @@ export async function reloadAgentTimelineFromPersistedReplica(
const timeline = cache?.hosts
?.flatMap((host) => host.timelines)
.find((candidate) => candidate.agentId === agent.agentId);
// This long fixture fills the latest 40-entry restart page, even after loading scrollback.
return timeline?.items?.length;
return timeline?.items?.length === 50;
})
.toBe(40);
.toBe(true);
await page.reload();
await expectTimelinePromptVisible(page, agent.newestPrompt);
@@ -504,7 +503,7 @@ export async function holdBootstrapTimelinePage(
page: Page,
agent: LongTimelineAgent,
): Promise<BootstrapTimelineGate> {
return delayAgentBootstrapTimelineResponse(page, agent.agentId);
return delayAgentBootstrapTailResponse(page, agent.agentId);
}
export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise<string> {
@@ -48,14 +48,6 @@ export function expectOneResumeCheckWithoutTail(
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(0);
}
export function expectEmptyBaselineBootstrapsWithOneTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
expect(gate.getTimelineRequestCount("after") - before.after).toBe(0);
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1);
}
export function expectResumeOverflowFallsBackToOneTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
@@ -38,6 +38,12 @@ function HostAgentReadyRouteContent() {
}
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
});
useEffect(() => {
if (!serverId || !agentId || agentWorkspaceId) return;
void getHostRuntimeStore()
.prepareAgentRoute(serverId, agentId)
.catch(() => undefined);
}, [agentId, agentWorkspaceId, serverId]);
const shouldLookupAgent = Boolean(
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
);
@@ -2,34 +2,17 @@
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "./create-flow";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { defaultHostAppearance } from "@/hosts/appearance";
describe("useDraftAgentCreateFlow", () => {
beforeEach(() => {
useCreateFlowStore.setState({ pendingByDraftId: {} });
getHostRuntimeStore().syncHosts([
{
serverId: "server-1",
label: "test",
appearance: defaultHostAppearance(),
lifecycle: {},
connections: [],
preferredConnectionId: null,
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
},
]);
});
afterEach(() => getHostRuntimeStore().syncHosts([]));
it("renders a prepared new-workspace submission before continuing it", async () => {
const image: UserMessageImageAttachment = {
id: "image-1",
+12 -13
View File
@@ -6,7 +6,7 @@ import {
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
import { useSessionStore } from "@/stores/session-store";
import {
createUserMessage,
@@ -214,18 +214,17 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
if (createResult.agentId) {
updatePendingAgentId({ draftId, agentId: createResult.agentId });
getHostRuntimeStore()
.getTimelineReplica(pendingServerId)
.handoffSubmission(
createResult.agentId,
createUserMessage({
clientMessageId: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp,
images: attempt.images,
attachments: attempt.attachments,
}),
);
handoffCreatedAgentMessageSubmission(
pendingServerId,
createResult.agentId,
createUserMessage({
clientMessageId: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp,
images: attempt.images,
attachments: attempt.attachments,
}),
);
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
}
+1 -4
View File
@@ -1484,10 +1484,7 @@ function ComposerContentImpl({
supportsForgeAttachments: supportsForgeSearch,
}),
encodeImages,
submission: createMessageSubmissionWriter(
serverId,
getHostRuntimeStore().getTimelineReplica(serverId),
),
submission: createMessageSubmissionWriter(serverId),
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
@@ -1,4 +1,3 @@
import { createTimelineReplica } from "@/timeline/replica";
import { afterEach, expect, test } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
@@ -41,17 +40,10 @@ afterEach(() => {
test("tracks submissions when canonical prompts are supported", () => {
initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: true });
createMessageSubmissionWriter(
CANONICAL_ONLY_SERVER_ID,
createTimelineReplica({
serverId: CANONICAL_ONLY_SERVER_ID,
storage: {
removeTimeline: () => undefined,
readTimeline: async () => undefined,
commitTimeline: () => undefined,
},
}),
).begin("agent-1", submittedMessage("canonical-only-message"));
createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID).begin(
"agent-1",
submittedMessage("canonical-only-message"),
);
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]);
});
+65 -12
View File
@@ -1,19 +1,72 @@
import type { MessageSubmissionWriter } from "@/composer/actions";
import { useSessionStore } from "@/stores/session-store";
import type { TimelineReplica } from "@/timeline/replica";
import {
appendSubmittedUserMessage,
removeSubmittedUserMessage,
type UserMessageItem,
} from "@/types/stream";
export function createMessageSubmissionWriter(
function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) {
const session = useSessionStore.getState().sessions[serverId];
if (!session) return;
const tail = session.agentStreamTail.get(agentId) ?? [];
const head = session.agentStreamHead.get(agentId) ?? [];
useSessionStore
.getState()
.setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message }));
}
function removeUntrackedSubmission(
serverId: string,
replica: TimelineReplica,
): MessageSubmissionWriter {
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6.
const tracked =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features
?.canonicalSubmittedPrompts === true;
agentId: string,
clientMessageId: string,
): void {
const session = useSessionStore.getState().sessions[serverId];
if (!session) return;
const tail = session.agentStreamTail.get(agentId) ?? [];
const head = session.agentStreamHead.get(agentId) ?? [];
useSessionStore
.getState()
.setAgentStreamState(
serverId,
agentId,
removeSubmittedUserMessage({ tail, head, clientMessageId }),
);
}
function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
return {
begin: (agentId, message) => replica.beginSubmission(agentId, message, tracked),
accept: (agentId, clientMessageId) => replica.acceptSubmission(agentId, clientMessageId),
reject: (agentId, clientMessageId) =>
replica.rejectSubmission(agentId, clientMessageId, tracked),
begin: (agentId, message) => appendUntrackedSubmission(serverId, agentId, message),
accept: () => undefined,
reject: (agentId, clientMessageId) => {
removeUntrackedSubmission(serverId, agentId, clientMessageId);
return "rejected";
},
};
}
export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
const supportsTrackedMessageSubmissions =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features
?.canonicalSubmittedPrompts === true;
if (!supportsTrackedMessageSubmissions) {
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6.
return createUntrackedMessageSubmissionWriter(serverId);
}
return {
begin: (agentId, message) =>
useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message),
accept: (agentId, clientMessageId) =>
useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId),
reject: (agentId, clientMessageId) =>
useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
};
}
export function handoffCreatedAgentMessageSubmission(
serverId: string,
agentId: string,
message: UserMessageItem,
): boolean {
return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
}
+54 -6
View File
@@ -15,7 +15,9 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
import {
consumeForcedTimelineTailReplacement,
type TimelineDeliveryMode,
type TimelineResponsePayload,
type ViewedTimelineOwner,
} from "@/timeline/viewed-timeline-sync";
import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages";
@@ -227,12 +229,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
// Zustand store actions
const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration);
const setAgents = useSessionStore((state) => state.setAgents);
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
// Track focused agent for heartbeat
@@ -245,6 +251,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const _sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
const appStateRef = useRef(AppState.currentState);
const forcedTimelineTailReplacements = useRef(new Set<string>());
const viewedTimelineSyncRef = useRef<ViewedTimelineOwner | null>(null);
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
@@ -410,15 +417,33 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
[serverId, upsertWorkspaceSetupProgress],
);
const applyTimelineResponse = useCallback((receivedPayload: TimelineResponsePayload) => {
const payload = consumeForcedTimelineTailReplacement(
receivedPayload,
forcedTimelineTailReplacements.current,
);
const owner = viewedTimelineSyncRef.current;
if (!owner) throw new Error("Viewed timeline owner is unavailable");
owner.applyTimelineResponse(payload);
}, []);
useEffect(() => {
const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents);
const initialDeliveryMode = getTimelineDeliveryMode(
client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline,
);
const sync = getHostRuntimeStore().getViewedTimelineOwner(serverId);
const detach = sync.attachTransport({
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
initialDeliveryMode,
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
readCursor: (agentId) => {
const timeline = selectAgentTimelineState(
useSessionStore.getState().sessions[serverId],
agentId,
);
return timeline.status === "synced" && timeline.range
? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq }
: undefined;
},
fetchPage: async (agentId, request) => {
const session = useSessionStore.getState().sessions[serverId];
const initKey = getInitKey(serverId, agentId);
@@ -449,8 +474,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
throw error;
}
},
fetchLatestTail: (agentId) =>
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch(), true),
fetchLatestTail: async (agentId) => {
forcedTimelineTailReplacements.current.add(agentId);
try {
return await getHostRuntimeStore().fetchAgentTimeline(
serverId,
agentId,
planTimelineTailFetch(),
);
} finally {
forcedTimelineTailReplacements.current.delete(agentId);
}
},
reportError: (error) => {
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
},
@@ -460,15 +495,17 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
},
});
viewedTimelineSyncRef.current = sync;
setViewedTimelineSync(serverId, sync);
sync.setActive(getIsAppVisible(appStateRef.current));
return () => {
if (viewedTimelineSyncRef.current === sync) {
viewedTimelineSyncRef.current = null;
}
detach();
setViewedTimelineSync(serverId, null);
sync.dispose();
};
}, [client, serverId, setInitializingAgents]);
}, [client, serverId, setInitializingAgents, setViewedTimelineSync]);
useEffect(() => {
viewedTimelineSyncRef.current?.setConnected(isConnected);
@@ -516,6 +553,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
});
const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => {
if (message.type !== "fetch_agent_timeline_response") return;
owner.flushStreamAgent(message.payload.agentId);
applyTimelineResponse(message.payload);
});
const unsubTimelineReplacement = client.on("agent.timeline.replacement", (message) => {
if (message.type !== "agent.timeline.replacement") return;
void requestTimelineReplacement(
@@ -721,6 +764,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
return () => {
unsubTimelineReplacement();
unsubAgentStream();
unsubAgentTimeline();
unsubProviderSubagentUpdate();
unsubAgentAttention();
unsubCheckoutStatusUpdate();
@@ -740,11 +784,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
queryClient,
serverId,
setIsPlayingAudio,
setAgentStreamTail,
setAgentStreamHead,
clearAgentStreamHead,
setInitializingAgents,
setAgents,
setPendingPermissions,
notifyAgentAttention,
applyWorkspaceSetupProgress,
applyTimelineResponse,
updateSessionServerInfo,
toast,
voiceRuntime,
@@ -53,15 +53,13 @@ describe("ensureAgentIsInitialized", () => {
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().initializeSession(serverId, client as never);
useSessionStore.getState().applyAgentTimelineResponseState(serverId, agentId, {
items: [],
head: [],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 42 },
older: "none",
newer: false,
synchronized: true,
submissions: [],
});
useSessionStore
.getState()
.setAgentTimelineCursor(
serverId,
new Map([[agentId, { epoch: "epoch-1", startSeq: 1, endSeq: 42 }]]),
);
useSessionStore.getState().setAgentAuthoritativeHistoryApplied(serverId, agentId, true);
void ensureAgentIsInitialized({
serverId,
@@ -131,7 +129,7 @@ describe("ensureAgentIsInitialized", () => {
older: "none",
newer: false,
synchronized: false,
submissions: [],
acknowledgedClientMessageIds: [],
});
void ensureAgentIsInitialized({
@@ -174,7 +172,7 @@ describe("ensureAgentIsInitialized", () => {
older: "available",
newer: false,
synchronized: true,
submissions: [],
acknowledgedClientMessageIds: [],
});
void ensureAgentIsInitialized({
@@ -61,7 +61,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
() => undefined,
() => undefined,
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
replica.commitSnapshot([entry(payload("live"))], []);
replica.applyTurnLiveness("agent", {
@@ -85,7 +84,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
() => undefined,
() => undefined,
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
expect(replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("network"))).toBe(
@@ -106,7 +104,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
() => undefined,
(mutations) => commits.push([...mutations]),
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
replica.commitSnapshot([entry(payload("agent"))], []);
commits.length = 0;
@@ -147,7 +144,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
(agentId) => stopped.push(agentId),
() => undefined,
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
replica.commitSnapshot([entry(payload("agent"))], []);
replica.applyTurnLiveness("agent", {
@@ -170,7 +166,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
() => undefined,
() => undefined,
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
replica.commitSnapshot(
[
@@ -224,7 +219,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
(agentId) => stopped.push(agentId),
() => undefined,
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
replica.commitSnapshot(
[
@@ -268,7 +262,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
() => undefined,
() => undefined,
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
replica.commitSnapshot(
[
@@ -296,16 +289,10 @@ describe("AgentDirectoryReplica", () => {
const serverId = "agent-replica";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const timelineLifetimes: string[] = [];
const replica = new AgentDirectoryReplica(
serverId,
() => undefined,
() => undefined,
(agentId) => {
timelineLifetimes.push(`remove:${agentId}`);
useSessionStore.getState().removeAgentTimeline(serverId, agentId);
},
(agentId) => timelineLifetimes.push(`accept:${agentId}`),
);
replica.commitSnapshot([entry(payload("directory"))], []);
const directoryPlacement = useSessionStore
@@ -332,7 +319,6 @@ describe("AgentDirectoryReplica", () => {
expect(
useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.projectPlacement,
).toEqual(directoryPlacement);
expect(timelineLifetimes).toEqual(["accept:agent", "remove:agent", "accept:agent"]);
store.clearSession(serverId);
});
});
@@ -4,7 +4,7 @@ import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import type { Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { type AgentDirectoryDelta, type AgentRemovalReason } from "@/utils/agent-directory-sync";
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
@@ -25,47 +25,52 @@ export interface AgentLifecycleToken {
export class AgentDirectoryReplica {
private readonly lifecycleVersions = new Map<string, number>();
private readonly members = new Set<string>();
private readonly pendingCacheReads = new Set<string>();
private readonly storeProjection: AgentStoreProjection;
constructor(
private readonly serverId: string,
private readonly onStoppedRunning: (agentId: string) => void,
private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void,
removeTimeline: (agentId: string, reason: AgentRemovalReason) => void,
private readonly acceptTimeline: (agentId: string) => void = () => undefined,
) {
this.storeProjection = new AgentStoreProjection(serverId, removeTimeline);
this.storeProjection = new AgentStoreProjection(serverId);
}
captureTimeline(agentId: string): AgentLifecycleToken {
return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 };
}
isTimelineCurrent(token: AgentLifecycleToken): boolean {
return token.version === (this.lifecycleVersions.get(token.agentId) ?? 0);
}
snapshot(): Map<string, Agent> {
return this.storeProjection.snapshot();
}
// Saved rows sit under whatever the live stream already delivered for this connection.
captureCache(agentId: string): AgentLifecycleToken {
this.pendingCacheReads.add(agentId);
return this.captureTimeline(agentId);
}
commitCached(agents: Map<string, Agent>): void {
const current = new Map(
[...agents].filter(([id]) => !this.lifecycleVersions.has(id) || this.members.has(id)),
);
const merged = this.storeProjection.commitCached(current);
const merged = this.storeProjection.commitCached(agents);
this.members.clear();
for (const agentId of merged.keys()) {
this.members.add(agentId);
}
}
commitCachedAgent(token: AgentLifecycleToken, agent: Agent): boolean {
this.pendingCacheReads.delete(token.agentId);
if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) return false;
if (this.members.has(agent.id)) return false;
this.members.add(agent.id);
this.storeProjection.accept(agent);
this.storeProjection.publishActivity(agent);
return true;
}
submitTimelineAgent(token: AgentLifecycleToken, payload: AgentSnapshotPayload): boolean {
if (!this.isTimelineCurrent(token)) {
if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) {
return false;
}
const startsLifetime = !this.members.has(token.agentId);
const existing = this.storeProjection.get(token.agentId);
const timelineAgent = applyLegacyDaemonWorkspaceOwnership({
serverId: this.serverId,
@@ -76,10 +81,6 @@ export class AgentDirectoryReplica {
projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement,
};
const accepted = this.storeProjection.accept(normalized);
if (startsLifetime) {
this.resume(accepted.id);
this.acceptTimeline(accepted.id);
}
this.members.add(accepted.id);
this.storeProjection.replacePendingPermissions(accepted);
this.storeProjection.publishActivity(accepted);
@@ -98,10 +99,7 @@ export class AgentDirectoryReplica {
this.advance(delta.agentId);
} else {
this.members.add(delta.agent.id);
if (!before) {
this.resume(delta.agent.id);
this.acceptTimeline(delta.agent.id);
}
if (!before) this.advance(delta.agent.id);
}
if (result.stoppedRunning) this.onStoppedRunning(result.agentId);
this.persist(
@@ -112,12 +110,7 @@ export class AgentDirectoryReplica {
}
accept(agent: Agent): Agent {
const startsLifetime = !this.members.has(agent.id);
const accepted = this.storeProjection.accept(agent);
if (startsLifetime) {
this.resume(accepted.id);
this.acceptTimeline(accepted.id);
}
this.members.add(accepted.id);
this.persist([this.agentUpsert(accepted)]);
return accepted;
@@ -131,23 +124,22 @@ export class AgentDirectoryReplica {
const previous = this.storeProjection.snapshot();
const reconciled = reconcileAgentDirectory({ snapshot: entries, deltas });
const nextIds = new Set(reconciled.map((entry) => entry.agent.id));
const startedLifetimes = new Set<string>();
for (const agentId of this.pendingCacheReads) {
if (!nextIds.has(agentId)) this.advance(agentId);
}
for (const agentId of this.members) {
if (!nextIds.has(agentId)) this.advance(agentId);
}
for (const agentId of nextIds) {
if (!this.members.has(agentId)) {
this.resume(agentId);
startedLifetimes.add(agentId);
}
if (!this.members.has(agentId)) this.advance(agentId);
}
for (const agentId of previous.keys()) {
if (!nextIds.has(agentId)) this.storeProjection.remove(agentId, "scope");
if (!nextIds.has(agentId)) this.storeProjection.remove(agentId);
}
this.members.clear();
this.pendingCacheReads.clear();
for (const agentId of nextIds) this.members.add(agentId);
const agents = this.storeProjection.replaceFetched(reconciled);
for (const agentId of startedLifetimes) this.acceptTimeline(agentId);
for (const [agentId, previousAgent] of previous) {
if (previousAgent.turn.phase === "open" && agents.get(agentId)?.turn.phase === "idle") {
this.onStoppedRunning(agentId);
@@ -209,7 +201,7 @@ export class AgentDirectoryReplica {
remove(agentId: string): void {
this.members.delete(agentId);
this.advance(agentId);
this.storeProjection.remove(agentId, "deleted");
this.storeProjection.remove(agentId);
this.persist([{ kind: "agent", type: "delete", id: agentId }]);
}
@@ -233,12 +225,6 @@ export class AgentDirectoryReplica {
};
}
private resume(agentId: string): void {
// First admission keeps requests already in flight. Re-entry invalidates work issued
// during the excluded lifetime as well as work issued before its removal.
if (this.lifecycleVersions.has(agentId)) this.advance(agentId);
}
private advance(agentId: string): void {
this.lifecycleVersions.set(agentId, (this.lifecycleVersions.get(agentId) ?? 0) + 1);
}
File diff suppressed because it is too large Load Diff
+166 -86
View File
@@ -3,6 +3,7 @@ import type {
FetchAgentsEntry,
FetchAgentsOptions,
} from "@getpaseo/client/internal/daemon-client";
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
import {
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
@@ -17,7 +18,7 @@ import {
shouldUseLegacyDaemonWorkspaceDirectory,
stampLegacyWorkspaceIds,
} from "@/workspace/legacy-daemon-workspaces";
import type { AgentDirectoryDelta, AgentRemovalReason } from "@/utils/agent-directory-sync";
import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { AgentDirectoryReplica } from "./agent-replica";
import {
WorkspaceDirectoryReplica,
@@ -32,14 +33,13 @@ import {
import { workspaceLabels } from "@/workspace-labels";
import type {
CachedDirectory,
CachedWorkspace,
DirectoryCheckpoint,
DirectoryCursor,
DirectoryReplicaMutation,
} from "@/runtime/replica-cache";
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
type TimelinePage = Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>;
const PAGE_LIMIT = 200;
const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
{ key: "updated_at", direction: "desc" },
@@ -108,6 +108,8 @@ export interface DirectoryConnection {
}
export interface DirectoryCheckpointStorage {
readAgent(serverId: string, agentId: string): Promise<Agent | undefined>;
readWorkspace(serverId: string, workspaceId: string): Promise<CachedWorkspace | undefined>;
readDirectory(serverId: string): Promise<CachedDirectory>;
commitDirectoryMutations(
serverId: string,
@@ -137,7 +139,6 @@ export class DirectorySync {
WorkspaceDirectorySnapshot,
WorkspaceDirectoryDelta
>();
private readonly timelineRequests = new Map<string, Promise<TimelinePage>>();
private readonly agents: AgentDirectoryReplica;
private readonly workspaces: WorkspaceDirectoryReplica;
private connection: DirectoryConnection = {
@@ -147,10 +148,10 @@ export class DirectorySync {
};
private unsubscribe: (() => void) | null = null;
private readonly abortSessionWaits = new Set<() => void>();
// The saved baseline is restored once, at construction. Live mutations apply as they arrive and
// the baseline merges under them; network refreshes wait for it so their transactions and
// cursors start from the saved state.
private readonly baseline: Promise<void>;
private cacheLoad: Promise<void> | null = null;
private cacheAccepted = false;
private revision = 0;
private workspaceRevision = 0;
private readonly routeDemandIds = new Set<string>();
private readonly fullDemandSources = new Set<object>();
private demandRefresh: Promise<void> | null = null;
@@ -160,10 +161,7 @@ export class DirectorySync {
constructor(
private readonly serverId: string,
private readonly callbacks: {
onTimelineRequest: (agentId: string, replaceTail: boolean) => (page: TimelinePage) => void;
onAgentStoppedRunning: (agentId: string) => void;
onAgentRemoved: (agentId: string, reason: AgentRemovalReason) => void;
onAgentAccepted?: (agentId: string) => void;
markAgentLoading: () => void;
markAgentReady: () => void;
markAgentError: (error: string) => void;
@@ -172,19 +170,8 @@ export class DirectorySync {
) {
const persist = (mutations: readonly DirectoryReplicaMutation[]) =>
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
this.agents = new AgentDirectoryReplica(
serverId,
callbacks.onAgentStoppedRunning,
persist,
callbacks.onAgentRemoved,
callbacks.onAgentAccepted,
);
this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist);
this.workspaces = new WorkspaceDirectoryReplica(serverId);
this.baseline = this.restoreBaseline().catch(() => undefined);
}
ready(): Promise<void> {
return this.baseline;
}
connectionChanged(connection: DirectoryConnection): boolean {
@@ -198,7 +185,6 @@ export class DirectorySync {
return false;
}
this.flushAbortedTransactions();
this.timelineRequests.clear();
this.unsubscribe?.();
this.unsubscribe = null;
workspaceLabels.disconnect(this.serverId);
@@ -210,39 +196,63 @@ export class DirectorySync {
const subscriptions = [
client.on("agent_update", (message) => {
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
if (!this.agentTransactions.record(source, message.payload)) {
this.revision += 1;
const recorded = this.agentTransactions.record(source, message.payload);
if (!recorded) {
this.agents.applyDelta(message.payload);
this.noteLiveCursor("agents", message.payload);
this.persistCheckpoint();
}
}),
client.on("workspace_update", (message) => {
if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return;
if (!this.workspaceTransactions.record(source, message.payload)) {
this.revision += 1;
this.workspaceRevision += 1;
const recorded = this.workspaceTransactions.record(source, message.payload);
if (!recorded) {
this.applyWorkspaceDelta(message.payload);
this.noteLiveCursor("workspaces", message.payload);
this.persistCheckpoint();
}
}),
client.on("project.update", (message) => {
if (message.type !== "project.update" || !this.isCurrent(client, source)) return;
if (!this.workspaceTransactions.record(source, message.payload)) {
this.revision += 1;
this.workspaceRevision += 1;
const recorded = this.workspaceTransactions.record(source, message.payload);
if (!recorded) {
this.applyWorkspaceDelta(message.payload);
this.noteLiveCursor("projects", message.payload);
this.persistCheckpoint();
}
}),
client.on("script_status_update", (message) => {
if (message.type !== "script_status_update" || !this.isCurrent(client, source)) return;
this.revision += 1;
this.workspaceRevision += 1;
const delta: WorkspaceDirectoryDelta = {
kind: "script_status",
update: message.payload,
};
if (!this.workspaceTransactions.record(source, delta)) {
const recorded = this.workspaceTransactions.record(source, delta);
if (!recorded) {
this.applyWorkspaceDelta(delta);
this.persistCheckpoint();
}
}),
client.on("agent_deleted", (message) => {
if (message.type !== "agent_deleted" || !this.isCurrent(client, source)) return;
this.agents.remove(message.payload.agentId);
if (message.type === "agent_deleted" && this.isCurrent(client, source)) {
this.revision += 1;
this.agents.remove(message.payload.agentId);
this.persistCheckpoint();
}
}),
client.on("agent_archived", (message) => {
if (message.type !== "agent_archived" || !this.isCurrent(client, source)) return;
this.agents.archive(message.payload.agentId, message.payload.archivedAt);
if (message.type === "agent_archived" && this.isCurrent(client, source)) {
this.revision += 1;
this.agents.archive(message.payload.agentId, message.payload.archivedAt);
this.persistCheckpoint();
}
}),
];
this.unsubscribe = () => {
@@ -256,8 +266,9 @@ export class DirectorySync {
const wasDemanded = this.fullDemandSources.size > 0;
if (demanded) this.fullDemandSources.add(source);
else this.fullDemandSources.delete(source);
if (!wasDemanded && this.fullDemandSources.size > 0 && this.getOnlineConnection()) {
void this.requestDemandRefresh().catch(() => undefined);
if (!wasDemanded && this.fullDemandSources.size > 0) {
void this.loadCachedDirectory().catch(() => undefined);
if (this.getOnlineConnection()) void this.requestDemandRefresh().catch(() => undefined);
}
}
@@ -277,8 +288,6 @@ export class DirectorySync {
}
dispose(): void {
this.connection = { ...this.connection, status: "offline" };
this.timelineRequests.clear();
this.flushAbortedTransactions();
this.abortPendingSessionWaits();
this.unsubscribe?.();
@@ -295,7 +304,9 @@ export class DirectorySync {
private requestDemandRefresh(force = false): Promise<void> {
if (this.demandRefresh) return this.demandRefresh;
if (!this.hasDemand()) return Promise.resolve();
if (!this.getOnlineConnection()) return this.baseline;
if (!this.getOnlineConnection()) {
return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve();
}
const source = this.connection.source;
if (
!force &&
@@ -308,8 +319,8 @@ export class DirectorySync {
this.fullDemandSources.size > 0
? this.refreshAll()
: Promise.all([
this.refreshAgents({ subscribe: {} }),
this.refreshWorkspaces({ subscribe: true }),
this.refreshAgentsInternal({ subscribe: {} }, false),
this.refreshWorkspacesInternal({ subscribe: true }, false),
]).then(() => undefined);
this.demandRefresh = refresh
.then(() => {
@@ -334,13 +345,57 @@ export class DirectorySync {
return this.requestDemandRefresh(true);
}
private async restoreBaseline(): Promise<void> {
async loadCachedAgent(agentId: string): Promise<void> {
if (!this.checkpoints) return;
const cached = await this.checkpoints.readDirectory(this.serverId);
if (!useSessionStore.getState().sessions[this.serverId]) return;
this.agents.commitCached(cached.agents);
this.workspaces.commitCached(cached);
this.cursors = cached.checkpoint ?? {};
if (useSessionStore.getState().sessions[this.serverId]?.agents.has(agentId)) return;
const token = this.agents.captureCache(agentId);
const agent = await this.checkpoints.readAgent(this.serverId, agentId);
if (!agent) return;
const session = useSessionStore.getState().sessions[this.serverId];
if (!session || session.agents.has(agentId)) return;
this.agents.commitCachedAgent(token, agent);
}
async prepareAgentRoute(agentId: string): Promise<void> {
await this.loadCachedAgent(agentId);
const agent = useSessionStore.getState().sessions[this.serverId]?.agents.get(agentId);
if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId);
}
async prepareWorkspaceRoute(workspaceId: string): Promise<void> {
await this.loadCachedWorkspace(workspaceId);
}
private async loadCachedWorkspace(workspaceId: string): Promise<void> {
if (!this.checkpoints) return;
if (useSessionStore.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
const revision = this.workspaceRevision;
const cached = await this.checkpoints.readWorkspace(this.serverId, workspaceId);
if (!cached) return;
if (this.workspaceRevision !== revision) return;
const session = useSessionStore.getState().sessions[this.serverId];
if (!session) return;
this.workspaces.commitCachedWorkspace(cached.workspace, cached.project);
}
private loadCachedDirectory(): Promise<void> {
const checkpoints = this.checkpoints;
if (!checkpoints) return Promise.resolve();
this.cacheLoad ??= (async () => {
const revision = this.revision;
const cached = await checkpoints.readDirectory(this.serverId);
if (this.cacheAccepted) return;
if (!useSessionStore.getState().sessions[this.serverId]) return;
this.agents.commitCached(cached.agents);
this.workspaces.commitCached(cached);
if (this.revision === revision) this.cursors = cached.checkpoint ?? {};
this.cacheAccepted = true;
})();
return this.cacheLoad;
}
restoreCachedDirectory(): Promise<void> {
return this.loadCachedDirectory();
}
applyAgentTurnLiveness(
@@ -357,36 +412,33 @@ export class DirectorySync {
async fetchTimeline(
agentId: string,
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
replaceTail = false,
): Promise<TimelinePage> {
const { client, source } = this.requireOnline();
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
const { client } = this.requireOnline();
const token = this.agents.captureTimeline(agentId);
const key = JSON.stringify({ agentId, lifetime: token.version, request, replaceTail });
const existing = this.timelineRequests.get(key);
if (existing) return existing;
const apply = this.callbacks.onTimelineRequest(agentId, replaceTail);
const fetching = client.fetchAgentTimeline(agentId, request).then((page) => {
if (!this.isCurrent(client, source) || !this.agents.isTimelineCurrent(token)) {
throw new DirectoryRefreshSupersededError("timeline page no longer current");
}
if (page.agent) this.agents.submitTimelineAgent(token, page.agent);
apply(page);
return page;
});
this.timelineRequests.set(key, fetching);
const clear = () => {
if (this.timelineRequests.get(key) === fetching) this.timelineRequests.delete(key);
};
void fetching.then(clear, clear);
return fetching;
const page = await fetchAgentTimelineOnce(client, agentId, request);
if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) {
this.revision += 1;
this.persistCheckpoint();
}
return page;
}
async refreshAgents(
input: RefreshAgentDirectoryInput = {},
): Promise<RefreshAgentDirectoryResult> {
return this.refreshAgentsInternal(input, true);
}
private async refreshAgentsInternal(
input: RefreshAgentDirectoryInput,
loadDirectoryCache: boolean,
): Promise<RefreshAgentDirectoryResult> {
if (loadDirectoryCache) {
this.primeAgentTransactionForCacheLoad();
await this.loadCachedDirectory();
}
const onlineConnection = this.getOnlineConnection();
if (!onlineConnection) {
await this.baseline;
this.callbacks.markAgentReady();
return {
agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents),
@@ -394,8 +446,6 @@ export class DirectorySync {
};
}
const { client, source } = onlineConnection;
// The transaction spans the whole refresh, so a live delta that arrives while the saved
// baseline or the session is still pending is replayed on top of whatever snapshot lands.
const transaction = this.agentTransactions.begin(source, () => ({
entries: [],
subscriptionId: null,
@@ -404,7 +454,6 @@ export class DirectorySync {
}));
this.callbacks.markAgentLoading();
try {
await this.baseline;
await this.waitForSession(client, source);
const session = useSessionStore.getState().sessions[this.serverId];
if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) {
@@ -461,8 +510,9 @@ export class DirectorySync {
: delta,
)
: completion.deltas;
this.revision += 1;
const agents = this.commitAgentSnapshot(completion.snapshot, deltas);
this.persistAgentCursors(completion.snapshot);
this.persistAgentCursors(completion.snapshot, completion.deltas);
this.persistCheckpoint();
this.callbacks.markAgentReady();
return { agents, subscriptionId: completion.snapshot.subscriptionId };
@@ -477,27 +527,26 @@ export class DirectorySync {
}
async refreshWorkspaces(input?: { subscribe?: boolean }): Promise<void> {
return this.refreshWorkspacesInternal(input, true);
}
private async refreshWorkspacesInternal(
input: { subscribe?: boolean } | undefined,
loadDirectoryCache: boolean,
): Promise<void> {
if (loadDirectoryCache) await this.loadCachedDirectory();
const onlineConnection = this.getOnlineConnection();
if (!onlineConnection) {
await this.baseline;
return;
}
if (!onlineConnection) return;
const { client, source } = onlineConnection;
// The transaction spans the whole refresh. Its base for a changes-mode page is the store once
// the saved baseline is in it, so it is captured after that wait rather than at begin.
const transaction = this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(),
projects: new Map(),
workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces),
projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects),
syncCursors: {},
syncModes: {},
touchedWorkspaceIds: new Set(),
touchedProjectIds: new Set(),
}));
try {
await this.baseline;
const session = useSessionStore.getState().sessions[this.serverId];
transaction.snapshot.workspaces = new Map(session?.workspaces);
transaction.snapshot.projects = new Map(session?.projects);
await this.waitForSessionMetadata(client, source);
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
if (serverInfo?.features?.workspaceMultiplicity !== true) {
@@ -676,6 +725,17 @@ export class DirectorySync {
return { client: this.connection.client, source: this.connection.source };
}
private primeAgentTransactionForCacheLoad(): void {
const connection = this.getOnlineConnection();
if (!connection) return;
this.agentTransactions.begin(connection.source, () => ({
entries: [],
subscriptionId: null,
legacy: false,
syncRemovals: [],
}));
}
private supportsDirectorySync(): boolean {
return (
useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync ===
@@ -692,8 +752,12 @@ export class DirectorySync {
: this.agents.commitSnapshot(snapshot.entries, deltas);
}
private persistAgentCursors(snapshot: AgentSnapshot): void {
private persistAgentCursors(
snapshot: AgentSnapshot,
deltas: readonly AgentDirectoryDelta[],
): void {
if (snapshot.syncCursor) this.writeCursor("agents", snapshot.syncCursor);
for (const delta of deltas) this.noteLiveCursor("agents", delta);
}
private async fetchProjectSnapshot(
@@ -737,6 +801,8 @@ export class DirectorySync {
if (completion.kind === "stale") {
throw new DirectoryRefreshSupersededError("workspace completion was superseded");
}
this.revision += 1;
this.workspaceRevision += 1;
const previous = this.readWorkspaceState();
const deltaMutations = this.workspaces.commitSnapshot(completion.snapshot, completion.deltas);
const next = this.workspaces.snapshot();
@@ -756,11 +822,14 @@ export class DirectorySync {
for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) {
if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor);
}
for (const delta of completion.deltas) {
if (delta.kind === "script_status") continue;
const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces";
this.noteLiveCursor(entity, delta);
}
this.persistCheckpoint();
}
// Only a completed catch-up may advance the saved cursor. A live sequence can follow an offline
// gap, so saving it would claim intermediate changes the client never received.
private persistCheckpoint(): void {
this.checkpoints?.commitDirectoryMutations(this.serverId, [], this.cursors);
}
@@ -805,6 +874,17 @@ export class DirectorySync {
snapshot.syncCursor = { generation: sync.generation, afterSeq: sync.headSeq };
}
private noteLiveCursor(
entity: keyof DirectoryCheckpoint,
payload: { generation?: string; seq?: number },
): void {
if (!payload.generation || payload.seq === undefined) return;
this.writeCursor(entity, {
generation: payload.generation,
afterSeq: payload.seq,
});
}
private readCursors(): DirectoryCheckpoint {
return this.cursors;
}
@@ -2,11 +2,7 @@ import equal from "fast-deep-equal";
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy";
import {
buildAgentDirectoryState,
type AgentDirectoryDelta,
type AgentRemovalReason,
} from "@/utils/agent-directory-sync";
import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
@@ -27,10 +23,7 @@ function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent
}
export class AgentStoreProjection {
constructor(
private readonly serverId: string,
private readonly removeTimeline: (agentId: string, reason: AgentRemovalReason) => void,
) {}
constructor(private readonly serverId: string) {}
snapshot(): Map<string, Agent> {
return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
@@ -72,7 +65,7 @@ export class AgentStoreProjection {
agent?: Agent;
} {
if (delta.kind === "remove") {
this.remove(delta.agentId, "scope");
this.remove(delta.agentId);
return { agentId: delta.agentId, stoppedRunning: false };
}
const normalized = normalizeAgentSnapshot(delta.agent, this.serverId);
@@ -148,7 +141,7 @@ export class AgentStoreProjection {
useSessionStore.getState().setPendingPermissions(this.serverId, pending);
}
remove(agentId: string, reason: AgentRemovalReason): void {
remove(agentId: string): void {
const store = useSessionStore.getState();
const removeKey = <T>(current: Map<string, T>): Map<string, T> => {
if (!current.has(agentId)) return current;
@@ -160,13 +153,16 @@ export class AgentStoreProjection {
store.setAgents(this.serverId, removeKey);
store.setAgentDetails(this.serverId, removeKey);
store.setQueuedMessages(this.serverId, removeKey);
this.removeTimeline(agentId, reason);
store.setAgentTimelineCursor(this.serverId, removeKey);
store.setInitializingAgents(this.serverId, removeKey);
store.setPendingPermissions(this.serverId, (current) => {
const next = new Map(current);
for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key);
return next.size === current.size ? current : next;
});
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
store.setAgentStreamTail(this.serverId, removeKey);
store.clearAgentStreamHead(this.serverId, agentId);
useSessionStore.setState((state) => {
if (!state.agentLastActivity.has(agentId)) return state;
const agentLastActivity = new Map(state.agentLastActivity);
@@ -213,7 +213,7 @@ it("does not invent a null-key project from a workspace update", () => {
store.clearSession(serverId);
});
it("does not restore a cached workspace while its archive is pending", () => {
it("does not restore a targeted cached workspace while its archive is pending", () => {
const serverId = "cached-workspace-during-archive";
const workspaceId = "archived-workspace";
const store = useSessionStore.getState();
@@ -222,10 +222,7 @@ it("does not restore a cached workspace while its archive is pending", () => {
markWorkspaceArchivePending({ serverId, workspaceId });
try {
replica.commitCached({
workspaces: new Map([[workspaceId, normalizeWorkspaceDescriptor(workspace(workspaceId))]]),
projects: new Map(),
});
replica.commitCachedWorkspace(normalizeWorkspaceDescriptor(workspace(workspaceId)), undefined);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
} finally {
@@ -58,6 +58,15 @@ export class WorkspaceDirectoryReplica {
useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true);
}
commitCachedWorkspace(
workspace: WorkspaceDescriptor,
project: ProjectDescriptor | undefined,
): void {
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) return;
if (project) this.setProject(project);
this.setWorkspace(workspace);
}
commitSnapshot(
snapshot: WorkspaceDirectorySnapshot,
deltas: readonly WorkspaceDirectoryDelta[],
+23 -326
View File
@@ -22,8 +22,6 @@ import {
type HostRuntimeStorage,
} from "./host-runtime";
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
import { ReplicaCache } from "./replica-cache";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
class FakeDaemonClient {
private state: ConnectionState = { status: "idle" };
@@ -39,13 +37,6 @@ class FakeDaemonClient {
public fetchAgentsResponses: Array<
Awaited<ReturnType<DaemonClient["fetchAgents"]>> | ReturnType<DaemonClient["fetchAgents"]>
> = [];
public timelineCalls = 0;
public timelineResponse: ReturnType<DaemonClient["fetchAgentTimeline"]> | null = null;
fetchAgentTimeline(): ReturnType<DaemonClient["fetchAgentTimeline"]> {
this.timelineCalls++;
if (!this.timelineResponse) throw new Error("Missing timeline response");
return this.timelineResponse;
}
public sentAgentMessages: Array<Parameters<DaemonClient["sendAgentMessage"]>> = [];
public sendAgentMessageFailures: Error[] = [];
public sendAgentMessageResponses: Promise<void>[] = [];
@@ -475,13 +466,16 @@ function createMemoryReplicaRowStore(): ReplicaRowStore {
`${row.serverId}:${row.kind}:${row.id}`;
return {
open: async () => undefined,
read: async (serverIds, kinds, ids) =>
[...rows.values()].filter(
read: async (serverId, kinds, ids) => {
const acceptedKinds = new Set(kinds);
const acceptedIds = ids ? new Set(ids) : null;
return [...rows.values()].filter(
(row) =>
serverIds.includes(row.serverId) &&
kinds.includes(row.kind) &&
(!ids || ids.includes(row.id)),
),
row.serverId === serverId &&
acceptedKinds.has(row.kind) &&
(!acceptedIds || acceptedIds.has(row.id)),
);
},
readAll: async () => {
const hosts = new Map<string, ReplicaRow[]>();
for (const row of rows.values()) {
@@ -1605,74 +1599,15 @@ describe("HostRuntimeStore", () => {
expect(store.getHosts()).toEqual([]);
});
it("exposes timeline demand and paints saved history before a network client exists", async () => {
const host = makeHost();
const storage = createMemoryHostRuntimeStorage({
"@paseo:daemon-registry": JSON.stringify([host]),
"@paseo:e2e": "1",
});
const replicaRowStore = createMemoryReplicaRowStore();
const disk = new ReplicaCache(replicaRowStore, { clearLegacyCache: async () => undefined });
disk.setHosts([host.serverId]);
disk.commitTimeline(host.serverId, "offline-agent", {
agentId: "offline-agent",
hasOlder: false,
range: { epoch: "offline-epoch", startSeq: 1, endSeq: 1 },
items: [
{
kind: "assistant_message",
id: "saved-message",
messageId: "saved-message",
text: "Saved history is available offline",
timestamp: new Date("2026-09-06T10:00:00.000Z"),
timelineCursor: { epoch: "offline-epoch", seq: 1 },
source: { startSeq: 1 },
},
],
});
await disk.flush();
const store = new HostRuntimeStore({
storage,
replicaRowStore,
deps: {
createClient: () => {
throw new Error("Offline demand must not need a client");
},
connectToDaemon: async () => {
throw new Error("Offline demand must not need a connection");
},
getClientId: async () => "cid_test_runtime",
},
});
try {
const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded());
store.boot();
await registryLoaded;
expect(useSessionStore.getState().sessions[host.serverId]?.client).toBeNull();
const demand = useSessionStore.getState().sessions[host.serverId]?.viewedTimelineSync;
expect(demand).not.toBeNull();
if (!demand) throw new Error("Registered hosts must expose offline timeline demand");
demand.registerVisibleAgentIds("workspace", ["offline-agent"]);
await vi.waitFor(() => {
expect(
useSessionStore.getState().sessions[host.serverId]?.agentStreamTail.get("offline-agent"),
).toMatchObject([{ text: "Saved history is available offline" }]);
});
} finally {
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
}
});
it("loads the host registry and restores its cached directory", async () => {
const host = makeHost();
const storage = createMemoryHostRuntimeStorage();
let directoryReads = 0;
let fullScans = 0;
const backingStore = createMemoryReplicaRowStore();
const replicaRowStore: ReplicaRowStore = {
...backingStore,
read: async (...args) => {
directoryReads += 1;
fullScans += 1;
return backingStore.read(...args);
},
};
@@ -1697,7 +1632,7 @@ describe("HostRuntimeStore", () => {
store.boot();
await registryLoaded;
await vi.waitFor(() => expect(directoryReads).toBe(1));
await vi.waitFor(() => expect(fullScans).toBe(1));
expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({
client: null,
hasHydratedAgents: false,
@@ -2394,15 +2329,10 @@ describe("HostRuntimeStore", () => {
host.serverId,
new Map([[recoveredAgent.agent.id, replicaAgent(recoveredAgent.agent, host.serverId)]]),
);
sessionStore.applyAgentTimelineResponseState(host.serverId, recoveredAgent.agent.id, {
items: [],
head: [],
range: { epoch: "epoch", startSeq: 10, endSeq: 20 },
older: "none",
newer: false,
synchronized: false,
submissions: [],
});
sessionStore.setAgentTimelineCursor(
host.serverId,
new Map([[recoveredAgent.agent.id, { epoch: "epoch", startSeq: 10, endSeq: 20 }]]),
);
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
const load = store.refreshAgentDirectory({
@@ -2825,9 +2755,8 @@ describe("HostRuntimeStore", () => {
getClientId: async () => "cid_drain_submission",
},
});
store.syncHosts([{ ...host, connections: [] }]);
const sessionStore = useSessionStore.getState();
sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.updateSessionServerInfo(host.serverId, {
serverId: host.serverId,
hostname: null,
@@ -2873,7 +2802,7 @@ describe("HostRuntimeStore", () => {
expect(session?.messageSubmissions.get("agent")).toBeDefined();
send.resolve();
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("restores an automatically drained message when sending fails", async () => {
@@ -2891,9 +2820,8 @@ describe("HostRuntimeStore", () => {
getClientId: async () => "cid_failed_queue_drain",
},
});
store.syncHosts([{ ...host, connections: [] }]);
const sessionStore = useSessionStore.getState();
sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.setQueuedMessages(
host.serverId,
new Map([
@@ -2919,7 +2847,7 @@ describe("HostRuntimeStore", () => {
]);
});
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("serializes queued-message drains for the same agent", async () => {
@@ -2938,9 +2866,8 @@ describe("HostRuntimeStore", () => {
getClientId: async () => "cid_serialized_queue_drain",
},
});
store.syncHosts([{ ...host, connections: [] }]);
const sessionStore = useSessionStore.getState();
sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.setQueuedMessages(
host.serverId,
new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]),
@@ -2957,7 +2884,7 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get("agent"),
).toEqual([]);
});
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => {
@@ -2974,9 +2901,8 @@ describe("HostRuntimeStore", () => {
getClientId: async () => "cid_legacy_queue_attachment",
},
});
store.syncHosts([{ ...host, connections: [] }]);
const sessionStore = useSessionStore.getState();
sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.updateSessionServerInfo(host.serverId, {
serverId: host.serverId,
hostname: null,
@@ -3215,235 +3141,6 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it.each(["coalesced", "late-tail", "rewind", "daemon-reset"] as const)(
"owns timeline request application (%s)",
async (scenario) => {
const host = makeHost({
serverId: `request-${scenario}`,
connections: [makeHost().connections[0]!],
});
const client = new FakeDaemonClient();
client.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
replicaRowStore: createMemoryReplicaRowStore(),
deps: {
createClient: () => client as unknown as DaemonClient,
connectToDaemon: async ({ host: current }) => ({
client: client as unknown as DaemonClient,
serverId: current.serverId,
hostname: null,
}),
getClientId: async () => "test",
},
});
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
const page: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>> = {
requestId: "page",
agentId: "agent",
agent: null,
direction: "tail",
projection: "projected",
reset: false,
staleCursor: false,
gap: false,
epoch: "epoch",
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch", seq: 1 },
endCursor: { epoch: "epoch", seq: 1 },
hasOlder: false,
hasNewer: false,
error: null,
entries: [
{
provider: "codex",
item: { type: "assistant_message", text: "first", messageId: "first" },
timestamp: "2026-09-06T00:00:00Z",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
},
],
};
const viewed = store.getViewedTimelineOwner(host.serverId);
if (scenario !== "coalesced") viewed.applyTimelineResponse(page);
const response = createDeferred<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>>();
client.timelineResponse = response.promise;
let publications = 0;
const unsubscribe = useSessionStore.subscribe((state, previous) => {
if (
state.sessions[host.serverId]?.agentStreamTail !==
previous.sessions[host.serverId]?.agentStreamTail
)
publications++;
});
const appendLive = () => {
viewed.enqueueStreamEvent("agent", {
epoch: "epoch",
seq: 2,
timestamp: new Date("2026-09-06T00:00:01Z"),
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "new live", messageId: "new-live" },
},
});
viewed.flushStreamAgent("agent");
};
if (scenario === "rewind") appendLive();
const request = planTimelineTailFetch();
const first = store.fetchAgentTimeline(
host.serverId,
"agent",
request,
scenario === "late-tail",
);
const second =
scenario === "coalesced"
? store.fetchAgentTimeline(host.serverId, "agent", request)
: first;
if (scenario === "late-tail" || scenario === "daemon-reset") appendLive();
if (scenario === "daemon-reset") page.reset = true;
response.resolve(page);
await expect(Promise.all([first, second])).resolves.toEqual([page, page]);
expect(client.timelineCalls).toBe(1);
if (scenario === "coalesced") expect(publications).toBe(1);
else {
const session = useSessionStore.getState().sessions[host.serverId];
expect(
[
...(session?.agentStreamTail.get("agent") ?? []),
...(session?.agentStreamHead.get("agent") ?? []),
].map((item) => item.id),
).toEqual(scenario === "late-tail" ? ["first", "new-live"] : ["first"]);
expect(store.getTimelineReplica(host.serverId).readCursor("agent")?.endSeq).toBe(
scenario === "late-tail" ? 2 : 1,
);
}
unsubscribe();
store.syncHosts([]);
},
);
it.each(["settled", "captured", "renamed"] as const)(
"retains scoped-out history after host identity reconciliation (%s) and deletes it only explicitly",
async (ordering) => {
const host = makeHost({
serverId: "srv_temporary",
connections: [makeHost().connections[0]!],
});
const resolvedId = "srv_resolved";
const rows = createMemoryReplicaRowStore();
const seed = new ReplicaCache(rows, { clearLegacyCache: async () => undefined });
seed.setHosts([host.serverId]);
const saved = {
agentId: "agent",
hasOlder: false,
range: { epoch: "epoch", startSeq: 1, endSeq: 1 },
items: [
{
kind: "assistant_message" as const,
id: "saved",
text: "saved history",
timestamp: new Date("2026-09-06T00:00:00Z"),
timelineCursor: { epoch: "epoch", seq: 1 },
source: { startSeq: 1 },
},
],
};
seed.commitTimeline(host.serverId, "agent", saved);
await seed.flush();
const client = new FakeDaemonClient();
client.setConnectionState({ status: "connected" });
const store = new HostRuntimeStore({
replicaRowStore: rows,
deps: {
createClient: () => client as unknown as DaemonClient,
connectToDaemon: async ({ host: current }) => ({
client: client as unknown as DaemonClient,
serverId: current.serverId,
hostname: null,
}),
getClientId: async () => "test",
},
});
const storedRows = async () =>
(await rows.readAll()).find((entry) => entry.serverId === resolvedId)?.rows ?? [];
const hasStoredKind = async (kind: ReplicaRow["kind"]) =>
(await storedRows()).some((row) => row.kind === kind);
try {
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
const replica = store.getTimelineReplica(host.serverId);
const started = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
const readRows = rows.read.bind(rows);
let reads = 0;
rows.read = async (...args) => {
if (!args[1].includes("timeline")) return readRows(...args);
reads++;
if (ordering === "settled" || reads !== 1) return readRows(...args);
const captured = ordering === "captured" ? await readRows(...args) : undefined;
started.resolve();
await release.promise;
return captured ?? readRows(...args);
};
const preparation = replica.prepare("agent");
if (ordering === "settled") {
await preparation;
expect(
useSessionStore.getState().sessions[host.serverId]?.agentStreamTail.get("agent"),
).toEqual(saved.items);
} else await started.promise;
store.reconcileServerId(host.serverId, resolvedId);
if (ordering !== "settled") {
await vi.waitFor(async () => expect(await hasStoredKind("timeline")).toBe(true));
release.resolve();
await preparation;
}
expect(replica.prepare("agent")).toBe(preparation);
expect(reads).toBe(ordering === "settled" ? 1 : 2);
expect(store.getTimelineReplica(resolvedId)).toBe(replica);
expect(
useSessionStore.getState().sessions[resolvedId]?.agentStreamTail.get("agent"),
).toEqual(saved.items);
expect(replica.readCursor("agent")).toEqual({ epoch: "epoch", endSeq: 1 });
await waitForHostOnline(store, resolvedId);
useSessionStore.getState().updateSessionServerInfo(resolvedId, {
serverId: resolvedId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
store.acceptAgentSnapshot(
resolvedId,
normalizeAgentSnapshot(
makeFetchAgentsEntry({ id: "agent", cwd: "/repo", updatedAt: "2026-09-06T00:00:00Z" })
.agent,
resolvedId,
),
);
await vi.waitFor(async () => expect(await hasStoredKind("agent")).toBe(true), {
timeout: 3000,
});
await store.refreshAgentDirectory({ serverId: resolvedId });
store.setAppVisible(false);
await vi.waitFor(async () => expect(await hasStoredKind("agent")).toBe(false));
const reopened = new ReplicaCache(rows, { clearLegacyCache: async () => undefined });
reopened.setHosts([resolvedId]);
expect(await reopened.readTimeline(resolvedId, "agent")).toEqual(saved);
store.restoreAgentSnapshot(resolvedId, "agent", undefined);
store.setAppVisible(false);
await vi.waitFor(async () => expect(await hasStoredKind("timeline")).toBe(false));
} finally {
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
useSessionStore.getState().clearSession(resolvedId);
}
},
);
it("replaces stale active session state when active bootstrap omits an agent", async () => {
const host = makeHost({
serverId: "srv_archived_rehydrate",
+52 -50
View File
@@ -1,4 +1,3 @@
import { createTimelineReplica, type TimelineReplica } from "@/timeline/replica";
import { useSyncExternalStore, useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import equal from "fast-deep-equal/es6";
@@ -71,8 +70,11 @@ import { ReplicaCache } from "@/runtime/replica-cache";
import type { ReplicaRowStore } from "@/runtime/replica-cache/row-store";
import { createReplicaRowStore } from "@/runtime/replica-cache/row-store-factory";
import {
createTimelineReplica,
createViewedTimelineOwner,
type TimelineReplica,
type ViewedTimelineOwner,
type ViewedTimelineOwnerPorts,
} from "@/timeline/viewed-timeline-sync";
import { projectIconCache } from "@/projects/icon-cache";
import { nativePerformanceTrace } from "@/performance/native-trace";
@@ -1402,7 +1404,6 @@ export class HostRuntimeStore {
private directorySyncByServer = new Map<string, DirectorySync>();
private nextCancellationRequestId = 0;
private timelineReplicaByServer = new Map<string, TimelineReplica>();
private viewedTimelineByServer = new Map<string, ViewedTimelineOwner>();
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
private bootPromise: Promise<void> | null = null;
private appVisible = true;
@@ -1520,6 +1521,12 @@ export class HostRuntimeStore {
projectIconCache.setHosts(profiles.map((profile) => profile.serverId));
await projectIconCache.restore();
this.syncHosts(profiles);
for (const profile of profiles) {
void this.directorySyncByServer
.get(profile.serverId)
?.restoreCachedDirectory()
.catch(() => undefined);
}
} catch (error) {
console.error("[HostRuntime] Failed to load host registry from storage", error);
} finally {
@@ -1656,17 +1663,11 @@ export class HostRuntimeStore {
projectIconCache.reconcileServerId(oldServerId, newServerId);
this.directorySyncByServer.get(oldServerId)?.dispose();
this.directorySyncByServer.delete(oldServerId);
const timelineReplica = this.getTimelineReplica(oldServerId);
rekeyMap(this.timelineReplicaByServer, oldServerId, newServerId);
this.timelineReplicaByServer.delete(oldServerId);
const directory = new DirectorySync(
newServerId,
{
onTimelineRequest: (agentId, replaceTail) =>
this.getViewedTimelineOwner(newServerId).beginTimelineRequest(agentId, replaceTail),
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
onAgentRemoved: (agentId, reason) =>
this.getViewedTimelineOwner(newServerId).removeAgent(agentId, reason),
onAgentAccepted: (agentId) => this.getViewedTimelineOwner(newServerId).acceptAgent(agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
@@ -1674,16 +1675,18 @@ export class HostRuntimeStore {
this.replicaCache,
);
this.directorySyncByServer.set(newServerId, directory);
this.timelineReplicaByServer.set(
newServerId,
createTimelineReplica({
serverId: newServerId,
storage: this.replicaCache,
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
}),
);
controller.adoptReconciledServerId(newServerId);
const snapshot = controller.getSnapshot();
this.viewedTimelineByServer.get(oldServerId)?.dispose();
this.viewedTimelineByServer.delete(oldServerId);
this.clearHostReplica(oldServerId);
useSessionStore
.getState()
.initializeSession(newServerId, snapshot.client, snapshot.clientGeneration);
timelineReplica.reconcileServerId(newServerId);
this.installViewedTimelineOwner(newServerId);
this.syncSessionReplica(newServerId, snapshot);
directory.connectionChanged({
client: snapshot.client,
status: snapshot.connectionStatus === "online" ? "online" : "offline",
@@ -2054,9 +2057,6 @@ export class HostRuntimeStore {
this.connectionStatusStartedAtByServer.delete(serverId);
this.directorySyncByServer.get(serverId)?.dispose();
this.directorySyncByServer.delete(serverId);
this.viewedTimelineByServer.get(serverId)?.dispose();
this.viewedTimelineByServer.delete(serverId);
this.timelineReplicaByServer.get(serverId)?.dispose();
this.timelineReplicaByServer.delete(serverId);
this.clearHostReplica(serverId);
void controller.stop();
@@ -2086,13 +2086,7 @@ export class HostRuntimeStore {
const directory = new DirectorySync(
host.serverId,
{
onTimelineRequest: (agentId, replaceTail) =>
this.getViewedTimelineOwner(host.serverId).beginTimelineRequest(agentId, replaceTail),
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
onAgentRemoved: (agentId, reason) =>
this.getViewedTimelineOwner(host.serverId).removeAgent(agentId, reason),
onAgentAccepted: (agentId) =>
this.getViewedTimelineOwner(host.serverId).acceptAgent(agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
@@ -2105,9 +2099,9 @@ export class HostRuntimeStore {
createTimelineReplica({
serverId: host.serverId,
storage: this.replicaCache,
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
}),
);
this.installViewedTimelineOwner(host.serverId);
const initialSnapshot = controller.getSnapshot();
this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus);
this.connectionStatusStartedAtByServer.set(host.serverId, Date.now());
@@ -2219,7 +2213,7 @@ export class HostRuntimeStore {
supportsForgeAttachments,
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId, this.getTimelineReplica(serverId)),
submission: createMessageSubmissionWriter(serverId),
});
},
})
@@ -2349,7 +2343,6 @@ export class HostRuntimeStore {
setAppVisible(visible: boolean): void {
this.appVisible = visible;
for (const owner of this.viewedTimelineByServer.values()) owner.setActive(visible);
for (const controller of this.controllers.values()) {
controller.setReconnectEnabled(visible);
}
@@ -2398,40 +2391,49 @@ export class HostRuntimeStore {
return () => directory.setDemand(source, false);
}
getTimelineReplica(serverId: string): TimelineReplica {
const replica = this.timelineReplicaByServer.get(serverId);
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
return replica;
async prepareAgentRoute(serverId: string, agentId: string): Promise<void> {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
await directory.prepareAgentRoute(agentId);
}
async fetchAgentTimeline(
async prepareWorkspaceRoute(serverId: string, workspaceId: string): Promise<void> {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
await directory.prepareWorkspaceRoute(workspaceId);
}
async prepareAgentTimeline(serverId: string, agentId: string): Promise<void> {
const replica = this.timelineReplicaByServer.get(serverId);
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
await replica.prepare(agentId);
}
fetchAgentTimeline(
serverId: string,
agentId: string,
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
replaceTail = false,
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
return directory.fetchTimeline(agentId, request, replaceTail);
return directory.fetchTimeline(agentId, request);
}
getViewedTimelineOwner(serverId: string): ViewedTimelineOwner {
const owner = this.viewedTimelineByServer.get(serverId);
if (!owner) throw new Error(`Unknown host runtime for serverId ${serverId}`);
return owner;
}
private installViewedTimelineOwner(serverId: string): void {
const owner = createViewedTimelineOwner({
createViewedTimelineOwner(
serverId: string,
ports: ViewedTimelineOwnerPorts,
): ViewedTimelineOwner {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
const replica = this.timelineReplicaByServer.get(serverId);
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
return createViewedTimelineOwner({
serverId,
replica: this.getTimelineReplica(serverId),
replaceDemandedAgentIds: (ids) =>
this.directorySyncByServer.get(serverId)?.setAgentRouteDemand(ids),
drainQueuedAgentMessage: (id) => this.drainQueuedAgentMessage(serverId, id),
replica,
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId),
ports,
});
this.viewedTimelineByServer.set(serverId, owner);
owner.setActive(this.appVisible);
useSessionStore.getState().setViewedTimelineSync(serverId, owner);
}
private emit(serverId: string): void {
@@ -25,58 +25,41 @@ class MemoryStorage implements ReplicaRowStore {
readonly rows = new Map<string, ReplicaRow>();
readonly changes: ReplicaRowChanges[] = [];
readonly reads: Array<{
serverIds: readonly string[];
serverId: string;
kinds: readonly ReplicaRow["kind"][];
ids?: readonly string[];
}> = [];
writes = 0;
readAlls = 0;
cleanups = 0;
nextWriteFailure: Error | null = null;
nextOpenFailure: Error | null = null;
nextReadAllFailure: Error | null = null;
nextDeleteFailure: Error | null = null;
readGate: Promise<void> | null = null;
readAllGate: Promise<void> | null = null;
writeGate: Promise<void> | null = null;
renameGate: Promise<void> | null = null;
onRead: (() => void) | null = null;
private key(row: Pick<ReplicaRow, "serverId" | "kind" | "id">): string {
return `${row.serverId}:${row.kind}:${row.id}`;
}
async open(): Promise<void> {
if (this.nextOpenFailure) {
const error = this.nextOpenFailure;
this.nextOpenFailure = null;
throw error;
}
}
async open(): Promise<void> {}
async read(
serverIds: readonly string[],
serverId: string,
kinds: readonly ReplicaRow["kind"][],
ids?: readonly string[],
): Promise<ReplicaRow[]> {
this.reads.push({ serverIds, kinds, ids });
const rows = [...this.rows.values()];
this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) });
this.onRead?.();
await this.readGate;
return rows.filter(
const acceptedKinds = new Set(kinds);
const acceptedIds = ids ? new Set(ids) : null;
return [...this.rows.values()].filter(
(row) =>
serverIds.includes(row.serverId) &&
kinds.includes(row.kind) &&
(!ids || ids.includes(row.id)),
row.serverId === serverId &&
acceptedKinds.has(row.kind) &&
(!acceptedIds || acceptedIds.has(row.id)),
);
}
async readAll(): Promise<ReplicaHostRows[]> {
this.readAlls += 1;
await this.readAllGate;
if (this.nextReadAllFailure) {
const error = this.nextReadAllFailure;
this.nextReadAllFailure = null;
throw error;
}
const hosts = new Map<string, ReplicaRow[]>();
for (const row of this.rows.values()) {
const rows = hosts.get(row.serverId) ?? [];
@@ -88,7 +71,6 @@ class MemoryStorage implements ReplicaRowStore {
async apply(changes: ReplicaRowChanges): Promise<void> {
this.writes += 1;
await this.writeGate;
if (this.nextWriteFailure) {
const error = this.nextWriteFailure;
this.nextWriteFailure = null;
@@ -100,16 +82,10 @@ class MemoryStorage implements ReplicaRowStore {
}
async deleteHost(serverId: string): Promise<void> {
if (this.nextDeleteFailure) {
const error = this.nextDeleteFailure;
this.nextDeleteFailure = null;
throw error;
}
for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key);
}
async renameHost(oldServerId: string, newServerId: string): Promise<void> {
await this.renameGate;
for (const [key, row] of this.rows) {
if (row.serverId !== oldServerId) continue;
this.rows.delete(key);
@@ -194,7 +170,6 @@ function timelineItem(text = "Cached"): StreamItem {
text,
timestamp: new Date("2026-07-18T08:02:00.000Z"),
timelineCursor: { epoch: "epoch-1", seq: 12 },
source: { startSeq: 12, chunks: [{ seq: 12, offset: 0 }] },
};
}
@@ -266,359 +241,6 @@ function deleteDirectory(cache: ReplicaCache, serverId: string): void {
}
describe("ReplicaCache", () => {
it("paints requested rows while the unrelated global index is unavailable", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
commitDirectory(writer, SERVER_ID, directory());
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Requested"));
await writer.flush();
const release = deferred();
storage.readAllGate = release.promise;
storage.reads.length = 0;
const reader = createCache(storage);
reader.setHosts([SERVER_ID, "unrelated"]);
reader.commitTimeline("unrelated", "other", { ...timeline("Pending"), agentId: "other" });
const writing = reader.flush();
let painted = false;
const reading = Promise.all([
reader.readTimeline(SERVER_ID, "agent-1"),
reader.readDirectory(SERVER_ID),
]).then(([saved, restoredDirectory]) => {
expect(saved?.items).toEqual([timelineItem("Requested")]);
expect(restoredDirectory.agents.has("agent-1")).toBe(true);
painted = true;
return undefined;
});
try {
await expect.poll(() => painted, { timeout: 200 }).toBe(true);
expect(storage.reads).toEqual([
{ serverIds: [SERVER_ID], kinds: ["timeline"], ids: ["agent-1"] },
{
serverIds: [SERVER_ID],
kinds: ["agent", "workspace", "project", "checkpoint"],
ids: undefined,
},
]);
} finally {
release.resolve();
await Promise.all([writing, reading]);
}
});
it("overlays a commit that settles after a scoped read captures old disk rows", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Old"));
await writer.flush();
const reader = createCache(storage);
const release = deferred();
storage.readGate = release.promise;
const reading = reader.readTimeline(SERVER_ID, "agent-1");
await expect.poll(() => storage.reads.length).toBe(1);
reader.commitTimeline(SERVER_ID, "agent-1", timeline("New"));
await reader.flush();
release.resolve();
expect((await reading)?.items).toEqual([timelineItem("New")]);
});
it("invalidates an old scoped read across removal and immediate re-registration", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Old lifetime"));
await writer.flush();
const reader = createCache(storage);
const release = deferred();
storage.readGate = release.promise;
const reading = reader.readTimeline(SERVER_ID, "agent-1");
await expect.poll(() => storage.reads.length).toBe(1);
reader.setHosts([]);
reader.setHosts([SERVER_ID]);
release.resolve();
expect(await reading).toBeUndefined();
expect(await reader.readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
await reader.flush();
});
it.each([false, true])(
"uses the same certification for accepted and persisted rows (provenance=%s)",
async (provenance) => {
const storage = new MemoryStorage();
const cache = createCache(storage);
const items = ["block-1", "block-2"].map((id) => {
const item = timelineItem("Repeated markdown");
item.id = id;
if (item.kind === "assistant_message") item.messageId = "same-message";
item.source = provenance ? { startSeq: 12, chunks: [{ seq: 12, offset: 0 }] } : undefined;
return item;
});
const value = { ...timeline(), items };
cache.commitTimeline(SERVER_ID, "agent-1", value);
const accepted = await cache.readTimeline(SERVER_ID, "agent-1");
expect(accepted?.range).toEqual(provenance ? value.range : null);
expect(accepted?.items).toEqual(items);
await cache.flush();
const persisted = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
expect(persisted).toEqual(accepted);
},
);
it("serializes only the coalesced certified page, including reads of accepted commits", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
let encodings = 0;
class MeasuredDate extends Date {
override toISOString(): string {
encodings += 1;
return super.toISOString();
}
}
for (let seq = 1; seq <= 10; seq++) {
const item = {
...timelineItem(`row ${seq}`),
timestamp: new MeasuredDate("2026-07-18T08:02:00.000Z"),
timelineCursor: { epoch: "epoch", seq },
source: { startSeq: seq, chunks: [{ seq, offset: 0 }] },
};
cache.commitTimeline(
SERVER_ID,
"agent-1",
{
agentId: "agent-1",
items: [item],
range: { epoch: "epoch", startSeq: seq, endSeq: seq },
hasOlder: true,
},
true,
);
expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([item]);
}
expect(encodings).toBe(0);
await cache.flush();
expect(encodings).toBe(1);
expect((await createCache(storage).readTimeline(SERVER_ID, "agent-1"))?.range?.endSeq).toBe(10);
});
it("does not resurrect a removed host when its in-flight write completes", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
const release = deferred();
storage.writeGate = release.promise;
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Removed history"));
const writing = cache.flush();
await expect.poll(() => storage.writes).toBe(1);
cache.setHosts([]);
release.resolve();
await writing;
cache.setHosts([SERVER_ID]);
await cache.flush();
expect(await cache.readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
expect(storage.rows.size).toBe(0);
});
it("keeps rows written under a host renamed during their write", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
const release = deferred();
storage.writeGate = release.promise;
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Renamed while writing"));
const writing = cache.flush();
await expect.poll(() => storage.writes).toBe(1);
cache.reconcileServerId(SERVER_ID, "resolved-host");
release.resolve();
await writing;
await cache.flush();
expect((await cache.readTimeline("resolved-host", "agent-1"))?.items).toEqual([
timelineItem("Renamed while writing"),
]);
expect(await cache.readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
expect([...storage.rows.keys()]).toEqual(["resolved-host:timeline:agent-1"]);
});
it("writes a row replaced during its own write again", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
const release = deferred();
storage.writeGate = release.promise;
cache.commitTimeline(SERVER_ID, "agent-1", timeline("First"));
const writing = cache.flush();
await expect.poll(() => storage.writes).toBe(1);
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Second"));
release.resolve();
await writing;
await cache.flush();
expect(storage.writes).toBe(2);
expect((await createCache(storage).readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([
timelineItem("Second"),
]);
});
it("restores a timeline while another host's disk write is blocked", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
cache.setHosts([SERVER_ID, "other-host"]);
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Saved before restart"));
await cache.flush();
const release = deferred();
storage.writeGate = release.promise;
commitDirectory(cache, "other-host", directory());
const writing = cache.flush();
await expect.poll(() => storage.writes).toBe(2);
let restored: Awaited<ReturnType<ReplicaCache["readTimeline"]>>;
const reading = cache.readTimeline(SERVER_ID, "agent-1").then((value) => {
restored = value;
return value;
});
try {
await expect
.poll(() => restored?.items, { timeout: 200 })
.toEqual([timelineItem("Saved before restart")]);
} finally {
release.resolve();
await Promise.all([writing, reading]);
}
});
it("reads rows under a resolved host identity before the disk rename finishes", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Saved under temporary host ID"));
await writer.flush();
const reader = createCache(storage);
const release = deferred();
storage.renameGate = release.promise;
reader.reconcileServerId(SERVER_ID, "resolved-host");
expect((await reader.readTimeline("resolved-host", "agent-1"))?.items).toEqual([
timelineItem("Saved under temporary host ID"),
]);
release.resolve();
await reader.flush();
expect(storage.rows.has("resolved-host:timeline:agent-1")).toBe(true);
});
it("keeps rename collision precedence and accepted baselines during lazy reads", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
writer.setHosts([SERVER_ID, "resolved-host"]);
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Moved"));
writer.commitTimeline("resolved-host", "agent-1", timeline("Superseded target"));
commitDirectory(writer, SERVER_ID, directory());
await writer.flush();
const reader = createCache(storage);
reader.setHosts([SERVER_ID, "resolved-host"]);
const release = deferred();
storage.renameGate = release.promise;
reader.replaceDirectoryBaseline(SERVER_ID, {
...directory(),
workspaces: new Map(),
projects: new Map(),
});
reader.reconcileServerId(SERVER_ID, "resolved-host");
try {
expect((await reader.readTimeline("resolved-host", "agent-1"))?.items).toEqual([
timelineItem("Moved"),
]);
const accepted = await reader.readDirectory("resolved-host");
expect(accepted.agents.get("agent-1")?.serverId).toBe("resolved-host");
expect(accepted.workspaces.size).toBe(0);
} finally {
release.resolve();
await reader.flush();
}
const reopened = new ReplicaCache(storage, noLegacyCleanup);
reopened.setHosts(["resolved-host"]);
expect((await reopened.readDirectory("resolved-host")).workspaces.size).toBe(0);
expect((await reopened.readTimeline("resolved-host", "agent-1"))?.items).toEqual([
timelineItem("Moved"),
]);
});
it("returns a committed timeline before it is written", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Accepted"));
expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([
timelineItem("Accepted"),
]);
expect(storage.writes).toBe(0);
});
it("round-trips a row's source start and chunk provenance", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
cache.commitTimeline(SERVER_ID, "agent-1", {
...timeline(),
items: [
{
...timelineItem("Streamed"),
source: {
startSeq: 10,
chunks: [
{ seq: 10, offset: 0 },
{ seq: 12, offset: 4 },
],
},
},
],
});
await cache.flush();
const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
expect(restored?.items[0]).toMatchObject({ source: { startSeq: 10 } });
expect(restored?.items[0]?.source?.chunks).toEqual([
{ seq: 10, offset: 0 },
{ seq: 12, offset: 4 },
]);
});
it("persists the owner's forty-entry restart page from a long synced history", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
const items = Array.from({ length: 40 }, (_, index) => ({
...timelineItem(`Message ${index}`),
id: `message-${index}`,
timelineCursor: { epoch: "epoch-1", seq: index + 201 },
source: { startSeq: index + 201, chunks: [{ seq: index + 201, offset: 0 }] },
}));
cache.commitTimeline(SERVER_ID, "agent-1", {
agentId: "agent-1",
items,
range: { epoch: "epoch-1", startSeq: 201, endSeq: 240 },
hasOlder: true,
});
await cache.flush();
const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
expect(restored?.items).toHaveLength(40);
expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 201, endSeq: 240 });
expect(restored?.hasOlder).toBe(true);
});
it("encodes the owner's certified page without a per-timeline byte ceiling", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
const items = Array.from({ length: 40 }, (_, index) => ({
...timelineItem("x".repeat(20_000)),
id: `message-${index}`,
timelineCursor: { epoch: "epoch-1", seq: index + 1 },
source: { startSeq: index + 1, chunks: [{ seq: index + 1, offset: 0 }] },
}));
cache.commitTimeline(SERVER_ID, "agent-1", {
agentId: "agent-1",
items,
range: { epoch: "epoch-1", startSeq: 1, endSeq: 40 },
hasOlder: true,
});
await cache.flush();
const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
expect(restored?.items.map((item) => item.id)).toEqual(items.map((item) => item.id));
expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 40 });
expect(restored?.hasOlder).toBe(true);
});
it("does nothing until an owner explicitly commits data", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
@@ -787,10 +409,9 @@ describe("ReplicaCache", () => {
deleteDirectory(cache, SERVER_ID);
const restored = await cache.readDirectory(SERVER_ID);
expect(restored.agents.size).toBe(0);
expect(restored.workspaces.size).toBe(0);
expect(restored.projects.size).toBe(0);
expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined();
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
expect((await cache.readDirectory(SERVER_ID)).projects.size).toBe(0);
});
it("fails closed when an accepted deletion cannot be persisted before a read", async () => {
@@ -802,23 +423,25 @@ describe("ReplicaCache", () => {
deleteDirectory(cache, SERVER_ID);
expect((await cache.readDirectory(SERVER_ID)).workspaces.size).toBe(0);
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
});
it("applies a deletion accepted while a scoped directory read is still loading", async () => {
it("discards a durable read when the host changes while it is in flight", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
commitDirectory(writer, SERVER_ID, directory());
await writer.flush();
const release = deferred();
storage.readGate = release.promise;
const cache = createCache(storage);
commitDirectory(cache, SERVER_ID, directory());
await cache.flush();
const started = deferred();
const release = deferred();
storage.onRead = started.resolve;
storage.readGate = release.promise;
const reading = cache.readDirectory(SERVER_ID);
const reading = cache.readAgent(SERVER_ID, "agent-1");
await started.promise;
deleteDirectory(cache, SERVER_ID);
release.resolve();
expect((await reading).agents.size).toBe(0);
expect(await reading).toBeUndefined();
});
it("never reads a timeline older than an accepted deferred replacement", async () => {
@@ -890,6 +513,39 @@ describe("ReplicaCache", () => {
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
});
it("reads one requested agent and the focused timeline without scanning directory rows", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
commitDirectory(writer, SERVER_ID, directory());
writer.commitTimeline(SERVER_ID, "agent-1", timeline());
await writer.flush();
const reader = createCache(storage);
expect((await reader.readAgent(SERVER_ID, "agent-1"))?.id).toBe("agent-1");
expect((await reader.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([timelineItem()]);
expect(storage.reads).toEqual([
{ serverId: SERVER_ID, kinds: ["agent"], ids: ["agent-1"] },
{ serverId: SERVER_ID, kinds: ["timeline"], ids: ["agent-1"] },
]);
});
it("reads one requested workspace and its project without scanning the directory", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
commitDirectory(writer, SERVER_ID, directory());
await writer.flush();
const reader = createCache(storage);
const restored = await reader.readWorkspace(SERVER_ID, "workspace-1");
expect(restored?.workspace.id).toBe("workspace-1");
expect(restored?.project?.projectId).toBe("project-1");
expect(storage.reads).toEqual([
{ serverId: SERVER_ID, kinds: ["workspace"], ids: ["workspace-1"] },
{ serverId: SERVER_ID, kinds: ["project"], ids: ["project-1"] },
]);
});
it("treats a corrupt row as a scoped miss", async () => {
const storage = new MemoryStorage();
storage.rows.set(`${SERVER_ID}:agent:agent-1`, {
@@ -914,7 +570,6 @@ describe("ReplicaCache", () => {
const cache = createCache(storage);
const restored = await cache.readDirectory(SERVER_ID);
await cache.flush();
expect(restored.agents.size).toBe(0);
expect(restored.projects.get("project-1")?.projectDisplayName).toBe("Paseo");
@@ -946,7 +601,7 @@ describe("ReplicaCache", () => {
commitDirectory(cache, otherServerId, directory());
await cache.flush();
expect((await cache.readDirectory(SERVER_ID)).agents.has("agent-1")).toBe(false);
expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined();
cache.commitTimeline(otherServerId, "agent-1", timeline("x".repeat(1_000)));
await cache.flush();
@@ -972,9 +627,7 @@ describe("ReplicaCache", () => {
payload: "{bad",
});
const cache = createCache(storage);
expect((await cache.readDirectory(SERVER_ID)).agents.has("agent-1")).toBe(false);
await cache.flush();
expect(await createCache(storage).readAgent(SERVER_ID, "agent-1")).toBeUndefined();
expect(storage.changes.at(-1)).toMatchObject({
deletes: [{ serverId: SERVER_ID, kind: "agent", id: "agent-1" }],
@@ -1004,9 +657,7 @@ describe("ReplicaCache", () => {
payload: "{bad",
});
const cache = createCache(storage);
const restored = await cache.readDirectory(SERVER_ID);
await cache.flush();
const restored = await createCache(storage).readDirectory(SERVER_ID);
expect(restored.checkpoint).toEqual({
projects: { generation: "g", afterSeq: 4 },
@@ -1037,66 +688,6 @@ describe("ReplicaCache", () => {
]);
});
it.each(["nextOpenFailure", "nextReadAllFailure"] as const)(
"persists an accepted commit automatically after %s",
async (failure) => {
const storage = new MemoryStorage();
const cache = createCache(storage);
storage[failure] = new Error("storage busy");
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Retry me"));
await cache.flush();
expect(storage.rows.size).toBe(0);
expect(await cache.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Retry me"));
await expect.poll(() => storage.rows.size, { timeout: 2500 }).toBe(1);
const reopened = createCache(storage);
expect(await reopened.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Retry me"));
},
);
it("recovers a partial index load with queued host changes and the exact surviving budget", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
writer.setHosts([SERVER_ID, "stale", "old", "removed"]);
for (const host of [SERVER_ID, "stale", "old", "removed"]) {
writer.commitTimeline(host, "agent-1", timeline("Stored"));
}
await writer.flush();
const survivorBytes = [...storage.rows.values()]
.filter((row) => row.serverId === SERVER_ID || row.serverId === "old")
.reduce((bytes, row) => bytes + Buffer.byteLength(row.payload), 0);
const cache = createCache(storage, survivorBytes);
cache.setHosts([SERVER_ID, "old", "removed"]);
const release = deferred();
storage.readAllGate = release.promise;
storage.nextDeleteFailure = new Error("stale host cleanup busy");
const loadsBefore = storage.readAlls;
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Newest"));
const writing = cache.flush();
try {
await expect.poll(() => storage.readAlls).toBe(loadsBefore + 1);
cache.reconcileServerId("old", "renamed");
cache.setHosts([SERVER_ID, "renamed"]);
} finally {
release.resolve();
await writing;
}
expect(await cache.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Newest"));
expect(await cache.readTimeline("renamed", "agent-1")).toEqual(timeline("Stored"));
expect(await cache.readTimeline("removed", "agent-1")).toBeUndefined();
await expect.poll(() => storage.changes.length, { timeout: 2500 }).toBe(2);
const reopened = createCache(storage);
reopened.setHosts([SERVER_ID, "renamed"]);
expect([...storage.rows.values()].map((row) => row.serverId).sort()).toEqual([
SERVER_ID,
"renamed",
]);
expect(await reopened.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Newest"));
expect(await reopened.readTimeline("renamed", "agent-1")).toEqual(timeline("Stored"));
});
it("retries an explicit commit after a storage failure", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
@@ -1112,6 +703,21 @@ describe("ReplicaCache", () => {
]);
});
it("retries a timeline read invalidated by a concurrent directory commit", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Persisted timeline"));
await cache.flush();
storage.onRead = () => {
storage.onRead = null;
commitDirectory(cache, SERVER_ID, directory());
};
expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([
timelineItem("Persisted timeline"),
]);
});
it("rebuilds every directory row before restoring its checkpoint after eviction", async () => {
const storage = new MemoryStorage();
const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 });
@@ -1153,7 +759,7 @@ describe("ReplicaCache", () => {
});
cache.setHosts([SERVER_ID]);
await cache.readTimeline(SERVER_ID, "missing");
await cache.readAgent(SERVER_ID, "missing");
commitDirectory(cache, SERVER_ID, directory());
await cache.flush();
File diff suppressed because it is too large Load Diff
@@ -108,17 +108,20 @@ export function createSqliteReplicaRowStore(
}
async function read(
serverIds: readonly string[],
serverId: string,
kinds: readonly ReplicaRow["kind"][],
ids?: readonly string[],
): Promise<ReplicaRow[]> {
if (!serverIds.length || !kinds.length || ids?.length === 0) return [];
const placeholders = (values: readonly string[]) => values.map(() => "?").join(",");
const rows = await getConnection().all<StoredRow>(
`SELECT server_id, kind, id, payload FROM rows WHERE server_id IN (${placeholders(serverIds)}) AND kind IN (${placeholders(kinds)})${ids ? ` AND id IN (${placeholders(ids)})` : ""} ORDER BY server_id, kind, id`,
[...serverIds, ...kinds, ...(ids ?? [])],
if (kinds.length === 0 || ids?.length === 0) return [];
const placeholders = kinds.map(() => "?").join(", ");
const idClause = ids ? ` AND id IN (${ids.map(() => "?").join(", ")})` : "";
const storedRows = await getConnection().all<StoredRow>(
`SELECT server_id, kind, id, payload FROM rows
WHERE server_id = ? AND kind IN (${placeholders})${idClause}
ORDER BY kind, id`,
[serverId, ...kinds, ...(ids ?? [])],
);
return rows.map((row) => ({
return storedRows.map((row) => ({
serverId: row.server_id,
kind: row.kind,
id: row.id,
@@ -55,23 +55,20 @@ export function runReplicaRowStoreContract(
]);
});
it("reads only requested hosts, kinds and identities", async () => {
const selected = row("server-a", "timeline", "wanted", "page");
it("reads only requested kinds for one host", async () => {
await store.apply({
deletes: [],
upserts: [
selected,
row("server-a", "timeline", "other", "unrelated timeline"),
row("server-a", "agent", "wanted", "directory"),
row("server-b", "timeline", "wanted", "other host"),
row("server-a", "agent", "agent-1", "agent"),
row("server-a", "timeline", "singleton", "timeline"),
row("server-b", "agent", "agent-2", "other host"),
],
deletes: [],
});
expect(await store.read(["server-a"], ["timeline"], ["wanted"])).toEqual([selected]);
expect(await store.read(["server-a"], ["agent"])).toEqual([
row("server-a", "agent", "wanted", "directory"),
expect(await store.read("server-a", ["timeline"])).toEqual([
row("server-a", "timeline", "singleton", "timeline"),
]);
expect(await store.read([], ["timeline"])).toEqual([]);
expect(await store.read(["server-a"], ["timeline"], [])).toEqual([]);
expect(await store.read("server-a", ["agent"], ["missing"])).toEqual([]);
});
it("overwrites an existing row on upsert", async () => {
@@ -25,7 +25,7 @@ export interface ReplicaHostRows {
export interface ReplicaRowStore {
open(): Promise<void>;
read(
serverIds: readonly string[],
serverId: string,
kinds: readonly ReplicaRowKind[],
ids?: readonly string[],
): Promise<ReplicaRow[]>;
@@ -24,41 +24,28 @@ runReplicaRowStoreContract("IndexedDB", async () => {
};
});
it("reads requested IndexedDB keys without scanning unrelated rows", async () => {
const options = {
it("uses exact IndexedDB keys for targeted rows instead of scanning the host", async () => {
const store = createIndexedDbReplicaRowStore({
databaseName: `replica-row-store-targeted-${databaseSequence++}`,
schemaVersion: 1,
};
const writer = createIndexedDbReplicaRowStore(options);
await writer.open();
await writer.apply({
});
await store.open();
await store.apply({
upserts: [
{ serverId: "host-a", kind: "timeline", id: "agent", payload: "a" },
{ serverId: "host-b", kind: "timeline", id: "agent", payload: "b" },
{ serverId: "host-a", kind: "timeline", id: "unrelated", payload: "large history" },
{ serverId: "host-a", kind: "agent", id: "agent", payload: "metadata" },
{ serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" },
{ serverId: "server-a", kind: "workspace", id: "workspace-1", payload: "workspace" },
],
deletes: [],
});
const reader = createIndexedDbReplicaRowStore(options);
await reader.open();
const get = vi.spyOn(FakeIDBObjectStore.prototype, "get");
const getAll = vi.spyOn(FakeIDBObjectStore.prototype, "getAll");
const openCursor = vi.spyOn(FakeIDBObjectStore.prototype, "openCursor");
try {
expect(await reader.read(["host-a", "host-b"], ["timeline"], ["agent"])).toEqual([
{ serverId: "host-a", kind: "timeline", id: "agent", payload: "a" },
{ serverId: "host-b", kind: "timeline", id: "agent", payload: "b" },
]);
expect(get.mock.calls).toEqual([
[["host-a", "timeline", "agent"]],
[["host-b", "timeline", "agent"]],
]);
expect(getAll).not.toHaveBeenCalled();
expect(openCursor).not.toHaveBeenCalled();
} finally {
get.mockRestore();
getAll.mockRestore();
openCursor.mockRestore();
}
expect(await store.read("server-a", ["agent"], ["agent-1"])).toEqual([
{ serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" },
]);
expect(get).toHaveBeenCalledWith(["server-a", "agent", "agent-1"]);
expect(getAll).not.toHaveBeenCalled();
get.mockRestore();
getAll.mockRestore();
});
@@ -136,6 +136,38 @@ export function createIndexedDbReplicaRowStore(
return Array.from(hosts, ([serverId, hostRows]) => ({ serverId, rows: hostRows }));
}
async function read(
serverId: string,
kinds: readonly ReplicaRow["kind"][],
ids?: readonly string[],
): Promise<ReplicaRow[]> {
if (kinds.length === 0 || ids?.length === 0) return [];
const transaction = getDatabase().transaction(ROWS_STORE, "readonly");
const completion = transactionComplete(transaction);
const store = transaction.objectStore(ROWS_STORE);
const requests = ids
? kinds.flatMap((kind) =>
ids.map(async (id) => {
const row = await requestResult<ReplicaRow | undefined>(
store.get([serverId, kind, id]),
);
return row ? [row] : [];
}),
)
: kinds.map((kind) =>
requestResult<ReplicaRow[]>(
store.getAll(IDBKeyRange.bound([serverId, kind], [serverId, kind, []])),
),
);
const rows = (await Promise.all(requests)).flat();
await completion;
return rows.sort((left, right) =>
left.kind === right.kind
? left.id.localeCompare(right.id)
: left.kind.localeCompare(right.kind),
);
}
async function apply(changes: ReplicaRowChanges): Promise<void> {
await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => {
const rows = transaction.objectStore(ROWS_STORE);
@@ -148,39 +180,6 @@ export function createIndexedDbReplicaRowStore(
});
}
async function read(
serverIds: readonly string[],
kinds: readonly ReplicaRow["kind"][],
ids?: readonly string[],
): Promise<ReplicaRow[]> {
const transaction = getDatabase().transaction(ROWS_STORE, "readonly");
const completion = transactionComplete(transaction);
const store = transaction.objectStore(ROWS_STORE);
const requests: Promise<ReplicaRow[]>[] = [];
for (const serverId of serverIds) {
for (const kind of kinds) {
if (ids) {
for (const id of ids) {
requests.push(
requestResult<ReplicaRow | undefined>(store.get([serverId, kind, id])).then((row) =>
row ? [row] : [],
),
);
}
} else {
requests.push(
requestResult<ReplicaRow[]>(
store.getAll(IDBKeyRange.bound([serverId, kind], [serverId, kind, []])),
),
);
}
}
}
const rows = (await Promise.all(requests)).flat();
await completion;
return rows;
}
async function deleteHost(serverId: string): Promise<void> {
await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => {
const rows = transaction.objectStore(ROWS_STORE);
@@ -61,7 +61,6 @@ import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { traceInstant } from "@/performance/native-trace";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import type { ViewedTimelineDemandDeclaration } from "@/timeline/viewed-timeline-sync";
import {
canDismissPaneInLayout,
collectAllTabs,
@@ -1547,6 +1546,12 @@ function WorkspaceScreenContent({
[workspaceId],
);
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
useEffect(() => {
if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return;
void getHostRuntimeStore()
.prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId)
.catch(() => undefined);
}, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]);
const workspaceScripts = getWorkspaceScripts(workspaceDescriptor);
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
useWorkspaceRouteActions(normalizedServerId);
@@ -1909,23 +1914,25 @@ function WorkspaceScreenContent({
routeFocused: isRouteFocused,
focusedPaneOnly: syncFocusedPaneOnly,
});
const visibleTimelineDeclarationRef = useRef<ViewedTimelineDemandDeclaration | null>(null);
useEffect(() => {
for (const agentId of visibleAgentIds) {
void getHostRuntimeStore()
.prepareAgentTimeline(normalizedServerId, agentId)
.catch(() => undefined);
}
}, [normalizedServerId, visibleAgentIds]);
useLayoutEffect(() => {
if (!persistenceKey || !viewedTimelineSync) {
return;
}
const declaration = viewedTimelineSync.registerVisibleAgentIds(persistenceKey, []);
visibleTimelineDeclarationRef.current = declaration;
return () => {
if (visibleTimelineDeclarationRef.current === declaration) {
visibleTimelineDeclarationRef.current = null;
}
declaration.dispose();
};
}, [persistenceKey, viewedTimelineSync]);
useLayoutEffect(() => {
visibleTimelineDeclarationRef.current?.replace(visibleAgentIds);
viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, visibleAgentIds);
}, [persistenceKey, viewedTimelineSync, visibleAgentIds]);
useEffect(() => {
if (!persistenceKey || !viewedTimelineSync) {
return;
}
return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []);
}, [persistenceKey, viewedTimelineSync]);
const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId);
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
const focusedPaneAgentId = useMemo(() => {
+40 -178
View File
@@ -1,4 +1,3 @@
import { createTimelineReplica, type TimelineReplica } from "@/timeline/replica";
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -86,37 +85,8 @@ afterEach(() => {
useSessionStore.getState().clearSession("test-server");
});
let timelineReplica: TimelineReplica;
function initializeTestSession(): void {
useSessionStore.getState().initializeSession("test-server", null as unknown as DaemonClient);
timelineReplica = createTimelineReplica({
serverId: "test-server",
storage: {
removeTimeline: () => undefined,
readTimeline: async () => undefined,
commitTimeline: () => undefined,
},
});
}
function acknowledgeSubmission(agentId: string, clientMessageId: string): void {
timelineReplica.applyEvents(
agentId,
[
{
seq: undefined,
epoch: undefined,
timestamp: new Date("2026-07-31T10:00:01.000Z"),
event: {
type: "timeline",
provider: "claude",
item: { type: "user_message", text: clientMessageId, messageId: clientMessageId },
},
},
],
() => undefined,
);
}
function getTestSessionReferences() {
@@ -171,38 +141,16 @@ describe("agent task state", () => {
);
const tasks = [{ id: "1", text: "Inspect", status: "pending" as const, completed: false }];
const applyTasks = (items: typeof tasks) =>
timelineReplica.applyEvents(
"agent-1",
[
{
seq: undefined,
epoch: undefined,
timestamp: new Date("2026-08-11T10:00:00.000Z"),
event: { type: "timeline", provider: "claude", item: { type: "todo", items } },
},
],
() => undefined,
);
applyTasks(tasks);
useSessionStore
.getState()
.setAgentStreamState("test-server", "agent-1", { taskSnapshot: tasks });
useSessionStore.getState().setIsPlayingAudio("test-server", true);
applyTasks([...tasks]);
timelineReplica.applyEvents(
"agent-1",
[
{
seq: undefined,
epoch: undefined,
timestamp: new Date("2026-08-11T10:00:01.000Z"),
event: {
type: "timeline",
provider: "claude",
item: { type: "todo", items: [{ ...tasks[0], status: "completed", completed: true }] },
},
},
],
() => undefined,
);
useSessionStore
.getState()
.setAgentStreamState("test-server", "agent-1", { taskSnapshot: [...tasks] });
useSessionStore.getState().setAgentStreamState("test-server", "agent-1", {
taskSnapshot: [{ ...tasks[0], status: "completed", completed: true }],
});
unsubscribe();
expect(snapshots).toEqual([["Inspect"], ["Inspect"]]);
@@ -226,7 +174,7 @@ describe("agent task state", () => {
older: "none",
newer: false,
synchronized: true,
submissions: [],
acknowledgedClientMessageIds: [],
});
expect(useSessionStore.getState().sessions["test-server"]?.agentTasks.get("agent-1")).toEqual(
@@ -236,107 +184,6 @@ describe("agent task state", () => {
});
describe("agent timeline state", () => {
it("preserves unchanged projection maps and only rescans tasks when rows change", () => {
initializeTestSession();
const store = useSessionStore.getState();
let scans = 0;
const item: StreamItem = {
get kind() {
scans++;
return "assistant_message" as const;
},
id: "row",
text: "text",
timestamp: new Date(0),
};
const input = {
items: [item],
head: [],
range: { epoch: "epoch", startSeq: 1, endSeq: 1 },
older: "available" as const,
newer: false,
synchronized: true,
submissions: [],
};
store.applyAgentTimelineResponseState("test-server", "agent-1", input);
const before = useSessionStore.getState();
const session = before.sessions["test-server"]!;
const scanned = scans;
let notices = 0;
const unsubscribe = useSessionStore.subscribe(() => notices++);
store.applyAgentTimelineResponseState("test-server", "agent-1", {
...input,
head: [],
range: { ...input.range },
submissions: [],
});
expect(useSessionStore.getState()).toBe(before);
expect(notices).toBe(0);
store.applyAgentTimelineResponseState("test-server", "agent-1", { ...input, newer: true });
const changed = useSessionStore.getState().sessions["test-server"]!;
expect(changed.agentTimelineHasNewer).not.toBe(session.agentTimelineHasNewer);
for (const key of [
"agentStreamTail",
"agentStreamHead",
"agentTimelineCursor",
"agentTimelineHasOlder",
"agentAuthoritativeHistoryApplied",
"agentHistorySyncGeneration",
"messageSubmissions",
"agentTasks",
] as const)
expect(changed[key]).toBe(session[key]);
expect(notices).toBe(1);
expect(scans).toBe(scanned);
store.bumpHistorySyncGeneration("test-server");
const stale = useSessionStore.getState().sessions["test-server"]!;
store.applyAgentTimelineResponseState("test-server", "agent-1", { ...input, newer: true });
const synced = useSessionStore.getState().sessions["test-server"]!;
expect(synced.agentHistorySyncGeneration.get("agent-1")).toBe(synced.historySyncGeneration);
expect(synced.agentHistorySyncGeneration).not.toBe(stale.agentHistorySyncGeneration);
expect(synced.agentStreamTail).toBe(stale.agentStreamTail);
expect(scans).toBe(scanned);
unsubscribe();
});
it("settles submissions and deletes optional entries while retaining an empty authoritative tail", () => {
initializeTestSession();
const store = useSessionStore.getState();
const input = {
items: [],
head: [],
range: null,
older: "none" as const,
newer: false,
synchronized: true,
submissions: [{ clientMessageId: "local", providerAcknowledged: false, rpcSettled: false }],
};
store.applyAgentTimelineResponseState("test-server", "agent-1", input);
const pending = useSessionStore.getState().sessions["test-server"]!;
store.applyAgentTimelineResponseState("test-server", "agent-1", {
...input,
submissions: [{ ...input.submissions[0]!, rpcSettled: true }],
});
const settled = useSessionStore.getState().sessions["test-server"]!;
expect(settled.messageSubmissions.get("agent-1")?.[0]?.rpcSettled).toBe(true);
expect(settled.agentStreamTail).toBe(pending.agentStreamTail);
store.applyAgentTimelineResponseState("test-server", "agent-1", { ...input, submissions: [] });
const empty = useSessionStore.getState();
const session = empty.sessions["test-server"]!;
expect(session.agentStreamTail.has("agent-1")).toBe(true);
expect(session.agentStreamHead.has("agent-1")).toBe(false);
expect(session.agentTimelineCursor.has("agent-1")).toBe(false);
expect(session.messageSubmissions.has("agent-1")).toBe(false);
expect(session.agentTasks.has("agent-1")).toBe(false);
store.applyAgentTimelineResponseState("test-server", "agent-1", {
...input,
items: [],
head: [],
submissions: [],
});
expect(useSessionStore.getState()).toBe(empty);
});
it("commits canonical items, range, and older availability as one synced state", () => {
initializeTestSession();
const items: StreamItem[] = [
@@ -355,7 +202,7 @@ describe("agent timeline state", () => {
older: "available",
newer: false,
synchronized: true,
submissions: [],
acknowledgedClientMessageIds: [],
});
expect(
@@ -378,7 +225,7 @@ describe("agent timeline state", () => {
older: "none",
newer: false,
synchronized: true,
submissions: [],
acknowledgedClientMessageIds: [],
});
expect(
@@ -396,7 +243,7 @@ describe("agent timeline state", () => {
older: "available",
newer: false,
synchronized: true,
submissions: [],
acknowledgedClientMessageIds: [],
});
store.applyAgentTimelineResponseState("test-server", "agent-1", {
@@ -406,7 +253,7 @@ describe("agent timeline state", () => {
older: "unchanged",
newer: false,
synchronized: false,
submissions: [],
acknowledgedClientMessageIds: [],
});
expect(
@@ -459,12 +306,15 @@ describe("message submission ordering", () => {
] as const;
function applyStep(step: (typeof steps)[number]): void {
const store = useSessionStore.getState();
if (step === "accept") {
timelineReplica.acceptSubmission(agentId, clientMessageId);
store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId);
return;
}
if (step === "canonical-row") {
acknowledgeSubmission(agentId, clientMessageId);
store.setAgentStreamState("test-server", agentId, {
acknowledgedClientMessageIds: [clientMessageId],
});
return;
}
if (step === "stream-open") {
@@ -501,7 +351,9 @@ describe("message submission ordering", () => {
for (const ordering of permutations(steps)) {
useSessionStore.getState().clearSession("test-server");
initializeTestSession();
timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true);
useSessionStore
.getState()
.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId));
let canonicalObserved = false;
for (const step of ordering) {
@@ -516,8 +368,11 @@ describe("message submission ordering", () => {
}
}
timelineReplica.acceptSubmission(agentId, clientMessageId);
acknowledgeSubmission(agentId, clientMessageId);
const store = useSessionStore.getState();
store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId);
store.setAgentStreamState("test-server", agentId, {
acknowledgedClientMessageIds: [clientMessageId],
});
applyTestTurn("test-server", agentId, [
{ type: "stream_close", turnId: "turn-1" },
{ type: "snapshot", activeTurn: null },
@@ -547,7 +402,9 @@ describe("message submission ordering", () => {
});
});
timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true);
useSessionStore
.getState()
.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId));
unsubscribe();
expect(notifications).toEqual([{ hasOptimisticRow: true, isActive: true }]);
@@ -555,10 +412,13 @@ describe("message submission ordering", () => {
it("keeps another unacknowledged submission active after one row is acknowledged", () => {
initializeTestSession();
timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true);
timelineReplica.beginSubmission(agentId, submittedMessage("client-2"), true);
timelineReplica.acceptSubmission(agentId, clientMessageId);
acknowledgeSubmission(agentId, clientMessageId);
const store = useSessionStore.getState();
store.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId));
store.beginAgentMessageSubmission("test-server", agentId, submittedMessage("client-2"));
store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId);
store.setAgentStreamState("test-server", agentId, {
acknowledgedClientMessageIds: [clientMessageId],
});
expect(
selectAgentTurnPresentation(useSessionStore.getState().sessions["test-server"], agentId)
@@ -570,7 +430,9 @@ describe("message submission ordering", () => {
initializeTestSession();
const message = submittedMessage(clientMessageId);
const handedOff = timelineReplica.handoffSubmission(agentId, message);
const handedOff = useSessionStore
.getState()
.handoffCreatedAgentUserMessage("test-server", agentId, message);
const session = useSessionStore.getState().sessions["test-server"];
expect({
+520 -95
View File
@@ -4,10 +4,22 @@ import { subscribeWithSelector } from "zustand/middleware";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import { type StreamItem, type TodoEntry } from "@/types/stream";
import {
appendSubmittedUserMessage,
handoffCreatedAgentUserMessageToStream,
removeSubmittedUserMessage,
type StreamItem,
type TodoEntry,
type UserMessageItem,
} from "@/types/stream";
import {
acceptMessageSubmission,
beginMessageSubmission,
getActiveMessageSubmissions,
observeMessageSubmissionCanonical,
rejectMessageSubmission,
type MessageSubmissionRecord,
type MessageSubmissionRejectionOutcome,
} from "@/composer/submission/model";
import type { PendingPermission } from "@/types/shared";
import type { ComposerAttachment } from "@/attachments/types";
@@ -284,18 +296,6 @@ export interface AgentTimelineCursorState {
}>;
}
function sameTimelineRange(
current: AgentTimelineCursorState | undefined,
next: AgentTimelineCursorState | null,
): boolean {
return (
current?.epoch === next?.epoch &&
current?.startSeq === next?.startSeq &&
current?.endSeq === next?.endSeq &&
current?.retainedRanges === next?.retainedRanges
);
}
export type AgentTimelineState =
| { status: "cold" }
| { status: "painted"; items: StreamItem[] }
@@ -453,13 +453,74 @@ interface SessionStoreActions {
setFocusedTerminalId: (serverId: string, terminalId: string | null) => void;
// Stream state (head/tail model)
setAgentStreamTail: (
serverId: string,
state:
| Map<string, StreamItem[]>
| ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>),
) => void;
setAgentStreamHead: (
serverId: string,
state:
| Map<string, StreamItem[]>
| ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>),
) => void;
setAgentStreamState: (
serverId: string,
agentId: string,
state: {
tail?: StreamItem[];
head?: StreamItem[];
acknowledgedClientMessageIds?: readonly string[];
taskSnapshot?: TodoEntry[];
},
) => void;
beginAgentMessageSubmission: (
serverId: string,
agentId: string,
message: UserMessageItem,
) => void;
acceptAgentMessageSubmission: (
serverId: string,
agentId: string,
clientMessageId: string,
) => void;
rejectAgentMessageSubmission: (
serverId: string,
agentId: string,
clientMessageId: string,
) => MessageSubmissionRejectionOutcome;
handoffCreatedAgentUserMessage: (
serverId: string,
agentId: string,
message: UserMessageItem,
) => boolean;
clearAgentStreamHead: (serverId: string, agentId: string) => void;
setAgentTimelineCursor: (
serverId: string,
state:
| Map<string, AgentTimelineCursorState>
| ((prev: Map<string, AgentTimelineCursorState>) => Map<string, AgentTimelineCursorState>),
) => void;
setAgentTimelineHasOlder: (
serverId: string,
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
) => void;
setAgentTimelineHasNewer: (
serverId: string,
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
) => void;
setAgentTimelineOlderFetchInFlight: (
serverId: string,
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
) => void;
bumpHistorySyncGeneration: (serverId: string) => void;
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
setAgentAuthoritativeHistoryApplied: (
serverId: string,
agentId: string,
applied: boolean,
) => void;
applyAgentTimelineResponseState: (
serverId: string,
agentId: string,
@@ -470,12 +531,10 @@ interface SessionStoreActions {
older: "available" | "none" | "unchanged";
newer: boolean;
synchronized: boolean;
submissions: MessageSubmissionRecord[];
acknowledgedClientMessageIds: string[];
},
) => void;
removeAgentTimeline: (serverId: string, agentId: string) => void;
// Initializing agents
setInitializingAgents: (
serverId: string,
@@ -872,6 +931,354 @@ export const useSessionStore = create<SessionStore>()(
},
// Stream state (head/tail model)
setAgentStreamTail: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const nextState = typeof state === "function" ? state(session.agentStreamTail) : state;
if (session.agentStreamTail === nextState) {
return prev;
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentStreamTail: nextState },
},
};
});
},
setAgentStreamHead: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const nextState = typeof state === "function" ? state(session.agentStreamHead) : state;
if (session.agentStreamHead === nextState) {
return prev;
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentStreamHead: nextState },
},
};
});
},
setAgentStreamState: (serverId, agentId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
let nextTail = session.agentStreamTail;
let nextHead = session.agentStreamHead;
let changedTail = false;
let changedHead = false;
if (state.tail !== undefined) {
const existingTail = session.agentStreamTail.get(agentId);
if (existingTail !== state.tail) {
nextTail = new Map(session.agentStreamTail);
nextTail.set(agentId, state.tail);
changedTail = true;
}
}
if (state.head !== undefined) {
const existingHead = session.agentStreamHead.get(agentId);
const shouldDeleteHead = state.head.length === 0;
if (shouldDeleteHead) {
if (session.agentStreamHead.has(agentId)) {
nextHead = new Map(session.agentStreamHead);
nextHead.delete(agentId);
changedHead = true;
}
} else if (existingHead !== state.head) {
nextHead = new Map(session.agentStreamHead);
nextHead.set(agentId, state.head);
changedHead = true;
}
}
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const observedSubmissions = observeMessageSubmissionCanonical(
currentSubmissions,
state.acknowledgedClientMessageIds ?? [],
);
const changedSubmissions = observedSubmissions !== currentSubmissions;
const agentTasks = updateAgentTasks(session.agentTasks, agentId, state.taskSnapshot);
const changedTasks = agentTasks !== session.agentTasks;
if (!changedTail && !changedHead && !changedSubmissions && !changedTasks) {
return prev;
}
let messageSubmissions = session.messageSubmissions;
if (changedSubmissions) {
messageSubmissions = new Map(session.messageSubmissions);
if (observedSubmissions.length > 0) {
messageSubmissions.set(agentId, observedSubmissions);
} else {
messageSubmissions.delete(agentId);
}
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agentStreamTail: nextTail,
agentStreamHead: nextHead,
agentTasks,
messageSubmissions,
},
},
};
});
},
beginAgentMessageSubmission: (serverId, agentId, message) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
if (!message.clientMessageId) {
throw new Error("Beginning a message submission requires client identity");
}
const currentTail = session.agentStreamTail.get(agentId) ?? [];
const currentHead = session.agentStreamHead.get(agentId) ?? [];
const stream = appendSubmittedUserMessage({
tail: currentTail,
head: currentHead,
message,
});
const submissions = beginMessageSubmission(
session.messageSubmissions.get(agentId) ?? [],
{ clientMessageId: message.clientMessageId },
);
const messageSubmissions = new Map(session.messageSubmissions);
messageSubmissions.set(agentId, submissions);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agentStreamTail:
stream.tail === currentTail
? session.agentStreamTail
: new Map(session.agentStreamTail).set(agentId, stream.tail),
agentStreamHead:
stream.head === currentHead
? session.agentStreamHead
: new Map(session.agentStreamHead).set(agentId, stream.head),
messageSubmissions,
},
},
};
});
},
acceptAgentMessageSubmission: (serverId, agentId, clientMessageId) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const submissions = acceptMessageSubmission(currentSubmissions, clientMessageId);
if (submissions === currentSubmissions) return prev;
const messageSubmissions = new Map(session.messageSubmissions);
if (submissions.length > 0) {
messageSubmissions.set(agentId, submissions);
} else {
messageSubmissions.delete(agentId);
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, messageSubmissions },
},
};
});
},
rejectAgentMessageSubmission: (serverId, agentId, clientMessageId) => {
let outcome: MessageSubmissionRejectionOutcome = "unknown";
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
const currentTail = session.agentStreamTail.get(agentId) ?? [];
const currentHead = session.agentStreamHead.get(agentId) ?? [];
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const result = rejectMessageSubmission(currentSubmissions, clientMessageId);
outcome = result.outcome;
if (outcome === "unknown") return prev;
const stream =
outcome === "rejected"
? removeSubmittedUserMessage({
tail: currentTail,
head: currentHead,
clientMessageId,
})
: { tail: currentTail, head: currentHead };
const messageSubmissions = new Map(session.messageSubmissions);
if (result.submissions.length > 0) {
messageSubmissions.set(agentId, result.submissions);
} else {
messageSubmissions.delete(agentId);
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agentStreamTail:
stream.tail === currentTail
? session.agentStreamTail
: new Map(session.agentStreamTail).set(agentId, stream.tail),
agentStreamHead:
stream.head === currentHead
? session.agentStreamHead
: new Map(session.agentStreamHead).set(agentId, stream.head),
messageSubmissions,
},
},
};
});
return outcome;
},
handoffCreatedAgentUserMessage: (serverId, agentId, message) => {
let didHandoff = false;
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const currentTail = session.agentStreamTail.get(agentId) ?? [];
const currentHead = session.agentStreamHead.get(agentId) ?? [];
const result = handoffCreatedAgentUserMessageToStream({
tail: currentTail,
head: currentHead,
message,
});
if (!result.changedTail && !result.changedHead) {
return prev;
}
const nextTail = result.changedTail
? new Map(session.agentStreamTail).set(agentId, result.tail)
: session.agentStreamTail;
const nextHead = result.changedHead
? new Map(session.agentStreamHead).set(agentId, result.head)
: session.agentStreamHead;
didHandoff = true;
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agentStreamTail: nextTail,
agentStreamHead: nextHead,
},
},
};
});
return didHandoff;
},
clearAgentStreamHead: (serverId, agentId) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
if (!session.agentStreamHead.has(agentId)) {
return prev;
}
const nextHead = new Map(session.agentStreamHead);
nextHead.delete(agentId);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentStreamHead: nextHead },
},
};
});
},
setAgentTimelineCursor: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const nextState =
typeof state === "function" ? state(session.agentTimelineCursor) : state;
if (session.agentTimelineCursor === nextState) {
return prev;
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentTimelineCursor: nextState },
},
};
});
},
setAgentTimelineHasOlder: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const nextState =
typeof state === "function" ? state(session.agentTimelineHasOlder) : state;
if (session.agentTimelineHasOlder === nextState) {
return prev;
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentTimelineHasOlder: nextState },
},
};
});
},
setAgentTimelineHasNewer: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
const nextState =
typeof state === "function" ? state(session.agentTimelineHasNewer) : state;
if (session.agentTimelineHasNewer === nextState) return prev;
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentTimelineHasNewer: nextState },
},
};
});
},
setAgentTimelineOlderFetchInFlight: (serverId, state) => {
set((prev) => {
@@ -914,58 +1321,107 @@ export const useSessionStore = create<SessionStore>()(
});
},
markAgentHistorySynchronized: (serverId, agentId) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const currentGeneration = session.historySyncGeneration;
const previousGeneration = session.agentHistorySyncGeneration.get(agentId);
if (previousGeneration === currentGeneration) {
return prev;
}
const nextMap = new Map(session.agentHistorySyncGeneration);
nextMap.set(agentId, currentGeneration);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agentHistorySyncGeneration: nextMap,
},
},
};
});
},
setAgentAuthoritativeHistoryApplied: (serverId, agentId, applied) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const previousApplied = session.agentAuthoritativeHistoryApplied.get(agentId) ?? false;
if (previousApplied === applied) {
return prev;
}
const nextApplied = new Map(session.agentAuthoritativeHistoryApplied);
if (applied) {
nextApplied.set(agentId, true);
} else {
nextApplied.delete(agentId);
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agentAuthoritativeHistoryApplied: nextApplied,
},
},
};
});
},
applyAgentTimelineResponseState: (serverId, agentId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
let changed = false;
const update = <T>(map: Map<string, T>, value: T | undefined): Map<string, T> => {
if (map.get(agentId) === value) return map;
changed = true;
const next = new Map(map);
if (value === undefined) next.delete(agentId);
else next.set(agentId, value);
return next;
};
const currentTail = session.agentStreamTail.get(agentId);
const items =
currentTail?.length === 0 && state.items.length === 0 ? currentTail : state.items;
const nextTail = update(session.agentStreamTail, items);
const nextHead = update(
session.agentStreamHead,
state.head.length > 0 ? state.head : undefined,
const nextTail = new Map(session.agentStreamTail);
nextTail.set(agentId, state.items);
const nextHead = new Map(session.agentStreamHead);
if (state.head.length > 0) nextHead.set(agentId, state.head);
else nextHead.delete(agentId);
const nextCursor = new Map(session.agentTimelineCursor);
if (state.range) nextCursor.set(agentId, state.range);
else nextCursor.delete(agentId);
const nextHasOlder = new Map(session.agentTimelineHasOlder);
if (state.older !== "unchanged") {
nextHasOlder.set(agentId, state.older === "available");
}
const nextHasNewer = new Map(session.agentTimelineHasNewer);
nextHasNewer.set(agentId, state.newer);
const nextAuthoritative = new Map(session.agentAuthoritativeHistoryApplied);
const nextSyncGeneration = new Map(session.agentHistorySyncGeneration);
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const observedSubmissions = observeMessageSubmissionCanonical(
currentSubmissions,
state.acknowledgedClientMessageIds,
);
const currentRange = session.agentTimelineCursor.get(agentId);
const rangeUnchanged = sameTimelineRange(currentRange, state.range);
const nextCursor = rangeUnchanged
? session.agentTimelineCursor
: update(session.agentTimelineCursor, state.range ?? undefined);
const nextHasOlder =
state.older === "unchanged"
? session.agentTimelineHasOlder
: update(session.agentTimelineHasOlder, state.older === "available");
const nextHasNewer = update(session.agentTimelineHasNewer, state.newer);
const nextAuthoritative = state.synchronized
? update(session.agentAuthoritativeHistoryApplied, true)
: session.agentAuthoritativeHistoryApplied;
const nextSyncGeneration = state.synchronized
? update(session.agentHistorySyncGeneration, session.historySyncGeneration)
: session.agentHistorySyncGeneration;
const messageSubmissions = update(
session.messageSubmissions,
state.submissions.length > 0 ? state.submissions : undefined,
);
const rowsChanged =
nextTail !== session.agentStreamTail || nextHead !== session.agentStreamHead;
const agentTasks = rowsChanged
? updateAgentTasks(
session.agentTasks,
agentId,
latestTasksFromStream([...state.items, ...state.head]),
)
: session.agentTasks;
if (!changed && agentTasks === session.agentTasks) return prev;
let messageSubmissions = session.messageSubmissions;
if (observedSubmissions !== currentSubmissions) {
messageSubmissions = new Map(session.messageSubmissions);
if (observedSubmissions.length > 0) {
messageSubmissions.set(agentId, observedSubmissions);
} else {
messageSubmissions.delete(agentId);
}
}
if (state.synchronized) {
nextAuthoritative.set(agentId, true);
nextSyncGeneration.set(agentId, session.historySyncGeneration);
}
const tasks = latestTasksFromStream([...state.items, ...state.head]);
const agentTasks = new Map(session.agentTasks);
if (tasks.length > 0) agentTasks.set(agentId, tasks);
else agentTasks.delete(agentId);
return {
...prev,
@@ -988,37 +1444,6 @@ export const useSessionStore = create<SessionStore>()(
});
},
removeAgentTimeline: (serverId, agentId) => {
set((previous) => {
const session = previous.sessions[serverId];
if (!session) return previous;
const remove = <T>(map: Map<string, T>): Map<string, T> => {
const next = new Map(map);
next.delete(agentId);
return next;
};
return {
...previous,
sessions: {
...previous.sessions,
[serverId]: {
...session,
agentStreamTail: remove(session.agentStreamTail),
agentStreamHead: remove(session.agentStreamHead),
agentTimelineCursor: remove(session.agentTimelineCursor),
agentTimelineHasOlder: remove(session.agentTimelineHasOlder),
agentTimelineHasNewer: remove(session.agentTimelineHasNewer),
agentTimelineOlderFetchInFlight: remove(session.agentTimelineOlderFetchInFlight),
agentAuthoritativeHistoryApplied: remove(session.agentAuthoritativeHistoryApplied),
agentHistorySyncGeneration: remove(session.agentHistorySyncGeneration),
messageSubmissions: remove(session.messageSubmissions),
agentTasks: remove(session.agentTasks),
},
},
};
});
},
// Initializing agents
setInitializingAgents: (serverId, state) => {
set((prev) => {
@@ -1,62 +0,0 @@
{
"agentId": "dd23a15e-0ece-4713-bff4-2f92b7293552",
"items": [
{
"id": "e793b735-19ca-4dae-b8a2-ef79ada78b40",
"timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 34 },
"turnId": "95e36741-5c73-4472-ab3c-3ba03977acfb",
"timestamp": "2026-08-29T11:04:38.577Z",
"kind": "assistant_message",
"messageId": "e793b735-19ca-4dae-b8a2-ef79ada78b40",
"text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather\n\n_(end of synthetic stream)_\n"
},
{
"id": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a",
"timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 69 },
"turnId": "c05e5c65-91dd-497e-8e59-92e045b4395f",
"timestamp": "2026-08-29T11:19:09.051Z",
"kind": "assistant_message",
"messageId": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a",
"text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather than guess. This kind of\n\n_(end of synthetic stream)_\n"
},
{
"id": "8b0dcf06-f4a7-4238-8ba1-87a4e9daaf83",
"timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 1 },
"turnId": "95e36741-5c73-4472-ab3c-3ba03977acfb",
"timestamp": "2026-08-29T11:04:36.573Z",
"kind": "user_message",
"messageId": "8b0dcf06-f4a7-4238-8ba1-87a4e9daaf83",
"text": "Exercise the composer keyboard invariant flow"
},
{
"id": "e793b735-19ca-4dae-b8a2-ef79ada78b40:segment:mte9wyi9",
"timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 34 },
"turnId": "95e36741-5c73-4472-ab3c-3ba03977acfb",
"timestamp": "2026-08-29T11:04:38.577Z",
"kind": "assistant_message",
"messageId": "e793b735-19ca-4dae-b8a2-ef79ada78b40",
"text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather\n\n_(end of synthetic stream)_\n"
},
{
"id": "msg_1788002346771_1hfh7ud4z",
"timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 35 },
"turnId": "c05e5c65-91dd-497e-8e59-92e045b4395f",
"timestamp": "2026-08-29T11:19:07.043Z",
"kind": "user_message",
"clientMessageId": "msg_1788002346771_1hfh7ud4z",
"messageId": "msg_1788002346771_1hfh7ud4z",
"text": "keyboard invariant line one\nline two\nline three\nline fourvv"
},
{
"id": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a:segment:mteafm63",
"timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 69 },
"turnId": "c05e5c65-91dd-497e-8e59-92e045b4395f",
"timestamp": "2026-08-29T11:19:09.051Z",
"kind": "assistant_message",
"messageId": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a",
"text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather than guess. This kind of\n\n_(end of synthetic stream)_\n"
}
],
"range": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "startSeq": 1, "endSeq": 69 },
"hasOlder": false
}
@@ -1,44 +0,0 @@
{
"entry": {
"provider": "codex",
"item": {
"type": "assistant_message",
"text": "\n\n---\n\nThe pending-tail check passed: the app requested catch-up after 10197 and recovered the live paragraph, with no subsequent live row delivered. I’m now checking that unpositioned updates can coexist with sequencing in the same session.\n",
"messageId": "msg_050818abff3697f1016a9e2d065a4487d287f39b8cdf226b1a"
},
"timestamp": "2026-09-07T03:18:31.760Z",
"seqStart": 10223,
"seqEnd": 10243,
"sourceSeqRanges": [
{
"startSeq": 10223,
"endSeq": 10243
}
],
"turnId": "codex-turn-64",
"collapsed": ["assistant_merge"]
},
"chunks": [
"\n\n---\n\nThe",
" pending-tail",
" check passed",
": the",
" app requested",
" catch-up",
" after ",
"10197",
" and recovered the",
" live paragraph",
", with no",
" subsequent live",
" row delivered",
". I",
"’m now checking that",
" unposition",
"ed",
" updates can coexist with",
" sequencing in",
" the same",
" session.\n"
]
}
@@ -0,0 +1,30 @@
import { expect, test } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { fetchAgentTimelineOnce } from "./fetch-agent-timeline-once";
type TimelinePage = Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>;
test("concurrent identical timeline reads share one request", async () => {
let resolvePage: (page: TimelinePage) => void = () => {};
const page = new Promise<TimelinePage>((resolve) => {
resolvePage = resolve;
});
const requests: Array<{ agentId: string; direction: string }> = [];
const client = {
fetchAgentTimeline: async (
agentId: string,
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
) => {
requests.push({ agentId, direction: request?.direction ?? "tail" });
return page;
},
};
const request = { direction: "tail" as const, limit: 100, projection: "projected" as const };
const first = fetchAgentTimelineOnce(client, "agent", request);
const second = fetchAgentTimelineOnce(client, "agent", request);
resolvePage({ hasNewer: false } as TimelinePage);
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
expect(requests).toEqual([{ agentId: "agent", direction: "tail" }]);
});
@@ -0,0 +1,31 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
type TimelineClient = Pick<DaemonClient, "fetchAgentTimeline">;
type TimelineRequest = Parameters<TimelineClient["fetchAgentTimeline"]>[1];
type TimelinePage = Awaited<ReturnType<TimelineClient["fetchAgentTimeline"]>>;
const inFlightByClient = new WeakMap<object, Map<string, Promise<TimelinePage>>>();
export function fetchAgentTimelineOnce(
client: TimelineClient,
agentId: string,
request: TimelineRequest,
): Promise<TimelinePage> {
let inFlight = inFlightByClient.get(client);
if (!inFlight) {
inFlight = new Map();
inFlightByClient.set(client, inFlight);
}
const key = `${agentId}:${JSON.stringify(request)}`;
const existing = inFlight.get(key);
if (existing) return existing;
const fetch = client.fetchAgentTimeline(agentId, request);
inFlight.set(key, fetch);
const clear = () => {
if (inFlight.get(key) === fetch) inFlight.delete(key);
};
void fetch.then(clear, clear);
return fetch;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-752
View File
@@ -1,752 +0,0 @@
import type { CachedTimeline } from "@/runtime/replica-cache";
import equal from "fast-deep-equal";
import type { AgentRemovalReason } from "@/utils/agent-directory-sync";
import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import {
acceptMessageSubmission,
beginMessageSubmission,
getSendingClientMessageIds,
observeMessageSubmissionCanonical,
rejectMessageSubmission,
type MessageSubmissionRecord,
type MessageSubmissionRejectionOutcome,
} from "@/composer/submission/model";
import {
appendSubmittedUserMessage,
handoffCreatedAgentUserMessageToStream,
removeSubmittedUserMessage,
replaceWithCanonicalStream,
isUnreconciledLocalUserMessage,
type StreamItem,
type UserMessageItem,
} from "@/types/stream";
import {
getInitDeferred,
getInitKey,
rejectInitDeferred,
resolveInitDeferred,
} from "@/utils/agent-initialization";
import {
processAgentStreamEvents,
processTimelineResponse,
processTimelineResponseCompletion,
type AgentStreamReducerEvent,
type AgentStreamReducerSnapshot,
type ProcessTimelineResponseOutput,
} from "./session-stream-reducers";
import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan";
import { TIMELINE_FETCH_PAGE_SIZE } from "./timeline-fetch-policy";
export type TimelineResponsePayload = Extract<
SessionOutboundMessage,
{ type: "fetch_agent_timeline_response" }
>["payload"];
export interface TimelinePageRequest {
cursor: { epoch: string; endSeq: number } | null;
replaceTail: boolean;
initialization: Promise<void> | undefined;
}
export interface TimelineReplicaStorage {
removeTimeline(serverId: string, agentId: string): void;
readTimeline(serverId: string, agentId: string): Promise<CachedTimeline | undefined>;
commitTimeline(
serverId: string,
agentId: string,
timeline: CachedTimeline,
requireCertified?: boolean,
): void;
}
interface TimelineRows {
tail: StreamItem[];
head: StreamItem[];
}
interface VerifiedTimeline extends TimelineRows {
range: AgentTimelineCursorState | null;
hasOlder: boolean;
}
interface AgentTimeline extends TimelineRows {
// Pagination and coverage certainty do not change which older entries belong to the restart page.
restartStartSeq: number | null;
verified: VerifiedTimeline | null;
// Loaded positions support navigation even when the displayed content is uncertifiable.
displayRange: AgentTimelineCursorState | null;
older: boolean;
newer: boolean;
synchronized: boolean;
hasUnsequencedHistory: boolean;
submissions: MessageSubmissionRecord[];
preparation?: Promise<void>;
restored: boolean;
liveEpoch?: string;
}
interface PageEffects {
recoverGap(
agentId: string,
cursor: { epoch: string; endSeq: number },
observedSeq?: number,
): void;
drainQueuedAgentMessage(agentId: string): void;
}
function acceptLiveDisplayEpoch(
state: AgentTimeline,
events: AgentStreamReducerEvent[],
): AgentStreamReducerEvent[] {
if (state.synchronized) return events;
// A connection's live epoch owns the display even while its first page is pending. The saved
// epoch remains a restart baseline until a page establishes a replacement.
const epoch = events.findLast((event) => event.event.type === "timeline" && event.epoch)?.epoch;
if (!epoch) return events;
const previousEpoch = state.liveEpoch ?? state.displayRange?.epoch;
if (previousEpoch && previousEpoch !== epoch) {
state.tail = state.tail.filter(
(item) => item.kind === "user_message" && isUnreconciledLocalUserMessage(item),
);
state.head = state.head.filter(
(item) => item.kind === "user_message" && isUnreconciledLocalUserMessage(item),
);
state.older = false;
state.displayRange = null;
state.restartStartSeq = null;
state.hasUnsequencedHistory = false;
}
state.liveEpoch = epoch;
return events.filter((event) => !event.epoch || event.epoch === epoch);
}
function applyVerifiedStreamEvents(
state: AgentTimeline,
events: AgentStreamReducerEvent[],
display: ReturnType<typeof processAgentStreamEvents>,
): boolean {
const previous = state.verified;
const positioned = events.filter(
({ event, seq, epoch }) =>
event.type !== "timeline" || (typeof seq === "number" && typeof epoch === "string"),
);
const unsequencedChanged =
positioned.length !== events.length && (display.changedTail || display.changedHead);
if (unsequencedChanged) state.hasUnsequencedHistory = true;
if (!previous || !state.synchronized) return unsequencedChanged;
const accepted =
positioned.length === events.length &&
state.tail === previous.tail &&
state.head === previous.head
? display
: processAgentStreamEvents({
events: positioned,
currentTail: previous.tail,
currentHead: previous.head,
currentCursor: previous.range ?? undefined,
hasAuthoritativeBaseline: true,
isDetached: state.newer,
});
const cursor = accepted.cursor;
const changedEpoch =
cursor !== null && previous.range !== null && cursor.epoch !== previous.range.epoch;
if (changedEpoch) {
Object.assign(display, retainPendingSubmissions(display, state));
if (accepted !== display) Object.assign(accepted, retainPendingSubmissions(accepted, state));
state.older = false;
state.hasUnsequencedHistory = unsequencedChanged;
state.restartStartSeq = cursor.startSeq;
}
state.verified = {
tail: accepted.tail,
head: accepted.head,
range: accepted.cursorChanged ? cursor : previous.range,
hasOlder: changedEpoch ? false : previous.hasOlder,
};
return (
unsequencedChanged || accepted.changedTail || accepted.changedHead || accepted.cursorChanged
);
}
function applySubmissionRows(
state: AgentTimeline,
transition: (rows: TimelineRows) => TimelineRows,
): boolean {
const verified = state.verified;
const shared = verified && state.tail === verified.tail && state.head === verified.head;
const result = transition(state);
const changed = result.tail !== state.tail || result.head !== state.head;
if (verified) {
const accepted = shared ? result : transition(verified);
verified.tail = accepted.tail;
verified.head = accepted.head;
}
state.tail = result.tail;
state.head = result.head;
return changed;
}
function isSupersededTail(
payload: TimelineResponsePayload,
current: AgentTimelineCursorState | null | undefined,
requestedFrom: { epoch: string; endSeq: number } | null | undefined,
): boolean {
if (payload.direction !== "tail" || payload.reset || payload.mergeWindow || payload.error)
return false;
// A head already behind the request's accepted cursor demonstrates a rewind. A head
// passed by activity accepted during that request is an obsolete reply instead.
return (
requestedFrom !== undefined &&
(requestedFrom === null ||
requestedFrom.epoch !== payload.epoch ||
payload.window.maxSeq >= requestedFrom.endSeq) &&
current?.epoch === payload.epoch &&
payload.window.maxSeq < current.endSeq
);
}
function invalidatesDurableBaseline(
payload: TimelineResponsePayload,
result: ProcessTimelineResponseOutput,
baseline: VerifiedTimeline | null,
): boolean {
const completeReplacement =
payload.direction === "tail" &&
(payload.window.maxSeq < payload.window.minSeq ||
(payload.startCursor?.seq === payload.window.minSeq &&
payload.endCursor?.seq === payload.window.maxSeq));
return Boolean(
result.commit === "apply" &&
completeReplacement &&
(payload.reset ||
(baseline?.range &&
(payload.epoch !== baseline.range.epoch || payload.window.maxSeq < baseline.range.endSeq))),
);
}
function applyAcceptedPageState(input: {
state: AgentTimeline;
payload: TimelineResponsePayload;
result: ProcessTimelineResponseOutput;
verified: VerifiedTimeline;
replaceTail: boolean;
synchronized: boolean;
}): void {
const { state, payload, result, verified, synchronized } = input;
const baseline = state.verified;
const range = verified.range;
const replacesWindow =
input.replaceTail ||
payload.reset ||
!baseline?.range ||
!range ||
baseline.range.epoch !== range.epoch ||
range.endSeq < baseline.range.endSeq;
// Older pagination can extend an existing baseline, but cannot establish live coverage.
if (payload.direction !== "before" || baseline) {
if (replacesWindow) state.restartStartSeq = range?.startSeq ?? null;
state.verified = verified;
if (payload.direction !== "before") state.hasUnsequencedHistory = false;
}
state.tail = result.tail;
state.head = result.head;
if (result.cursorChanged) state.displayRange = result.cursor ?? null;
if (result.older !== "unchanged") state.older = result.older === "available";
if (payload.direction !== "before") state.newer = payload.hasNewer;
if (synchronized) state.liveEpoch = payload.epoch;
state.synchronized ||= synchronized;
state.submissions = observeMessageSubmissionCanonical(
state.submissions,
result.acknowledgedClientMessageIds,
);
}
/** Host-lived write authority. Zustand is its UI projection; disk is write-behind. */
export class TimelineReplica {
private readonly agents = new Map<string, AgentTimeline>();
private readonly removed = new Set<string>();
private disposed = false;
constructor(
private serverId: string,
private readonly storage: TimelineReplicaStorage,
) {}
private state(agentId: string): AgentTimeline {
const existing = this.agents.get(agentId);
if (existing) return existing;
const state: AgentTimeline = {
tail: [],
head: [],
older: false,
newer: false,
synchronized: false,
hasUnsequencedHistory: false,
restored: false,
restartStartSeq: null,
verified: null,
displayRange: null,
submissions: [],
};
this.agents.set(agentId, state);
return state;
}
private accepts(agentId: string): boolean {
return !this.disposed && !this.removed.has(agentId);
}
prepare(agentId: string): Promise<void> {
if (!this.accepts(agentId)) return Promise.resolve();
const state = this.state(agentId);
state.preparation ??= this.restore(agentId, state);
return state.preparation;
}
private async restore(agentId: string, state: AgentTimeline): Promise<void> {
const serverId = this.serverId;
const stored = await this.storage.readTimeline(serverId, agentId);
if (!this.accepts(agentId) || this.agents.get(agentId) !== state) return;
// Identity transfer retires the old scoped read, even if it captured rows before rename.
if (serverId !== this.serverId) return this.restore(agentId, state);
state.restored = true;
if (state.verified || !stored) {
this.publish(agentId, state, {
persist: Boolean(state.verified) || state.hasUnsequencedHistory,
requireCertified: Boolean(stored?.range),
});
return;
}
const sameEpoch = !state.liveEpoch || !stored.range || state.liveEpoch === stored.range.epoch;
const replacement = replaceWithCanonicalStream({
canonical: sameEpoch ? stored.items : [],
previousTail: state.tail,
previousHead: state.head,
sendingClientMessageIds: getSendingClientMessageIds(state.submissions),
preserveContinuity: true,
canonicalCoverage:
sameEpoch && stored.range
? { epoch: stored.range.epoch, endSeq: stored.range.endSeq }
: "rows-only",
});
Object.assign(state, {
tail: replacement.tail,
head: replacement.head,
older: sameEpoch && stored.hasOlder,
displayRange: sameEpoch ? stored.range : null,
});
if (stored.range) {
state.restartStartSeq = stored.range.startSeq;
state.verified = {
tail: stored.items,
head: [],
range: stored.range,
hasOlder: stored.hasOlder,
};
}
state.submissions = observeMessageSubmissionCanonical(
state.submissions,
replacement.acknowledgedClientMessageIds,
);
this.publish(agentId, state, {
persist: state.hasUnsequencedHistory,
requireCertified: Boolean(stored.range),
});
}
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined {
if (!this.accepts(agentId)) return undefined;
const range = this.state(agentId).verified?.range;
return range ? { epoch: range.epoch, endSeq: range.endSeq } : undefined;
}
private streamSnapshot(agentId: string): AgentStreamReducerSnapshot {
const state = this.state(agentId);
return {
currentTail: state.tail,
currentHead: state.head,
currentCursor: state.displayRange ?? undefined,
// Before network synchronization, live activity can paint ahead of the saved range.
hasAuthoritativeBaseline: state.synchronized,
isDetached: state.synchronized && state.newer,
};
}
applyEvents(
agentId: string,
events: AgentStreamReducerEvent[],
recoverGap: PageEffects["recoverGap"],
): void {
if (!this.accepts(agentId)) return;
const state = this.state(agentId);
events = acceptLiveDisplayEpoch(state, events);
const display = processAgentStreamEvents({ events, ...this.streamSnapshot(agentId) });
const previous = state.verified;
const persist = applyVerifiedStreamEvents(state, events, display);
if (display.cursorChanged) state.displayRange = display.cursor;
state.tail = display.tail;
state.head = display.head;
state.submissions = observeMessageSubmissionCanonical(
state.submissions,
display.acknowledgedClientMessageIds,
);
this.publish(agentId, state, {
persist: persist || !previous,
requireCertified: Boolean(previous?.range && state.verified),
});
for (const effect of display.sideEffects) {
if (effect.type === "catch_up") recoverGap(agentId, effect.cursor, effect.observedSeq);
}
}
applyPage(
payload: TimelineResponsePayload,
effects: PageEffects,
request?: TimelinePageRequest,
): void {
const agentId = payload.agentId;
if (!this.accepts(agentId)) return;
const state = this.state(agentId);
const initKey = getInitKey(this.serverId, agentId);
const deferred = getInitDeferred(initKey);
const session = useSessionStore.getState().sessions[this.serverId];
const ownsInitialization = !request || request.initialization === deferred?.promise;
const common = {
payload,
replaceTail: request?.replaceTail === true,
currentCursor: state.displayRange ?? undefined,
isInitializing: ownsInitialization && session?.initializingAgents.get(agentId) === true,
hasActiveInitDeferred: ownsInitialization && Boolean(deferred),
initRequestDirection: deferred?.requestDirection ?? ("tail" as const),
sendingClientMessageIds: getSendingClientMessageIds(state.submissions),
};
if (isSupersededTail(payload, state.verified?.range, request?.cursor)) {
const completion = processTimelineResponseCompletion(common);
settleTimelineInitialization(this.serverId, agentId, completion);
return;
}
const result = processTimelineResponse({
...common,
currentTail: state.tail,
currentHead: state.head,
});
if (result.error) {
settleTimelineInitialization(this.serverId, agentId, result);
return;
}
const baseline = state.verified;
const invalidated = invalidatesDurableBaseline(payload, result, baseline);
if (invalidated) {
this.storage.removeTimeline(this.serverId, agentId);
state.restored = true;
}
const synchronized = isTimelineResumeSnapshotAuthoritative({
direction: payload.direction,
hasNewer: payload.hasNewer,
error: payload.error,
});
applyAcceptedPageState({
state,
payload,
result,
verified: reduceVerifiedPage(state, common, result),
replaceTail: common.replaceTail,
synchronized,
});
this.publish(agentId, state, {
persist: payload.direction !== "before",
requireCertified: !invalidated && Boolean(baseline?.range),
synchronized,
});
if (!this.accepts(agentId)) return;
recoverDisplayedGap(agentId, state, effects.recoverGap);
finalizeProcessedTimeline({
serverId: this.serverId,
agentId,
result,
synchronized,
...effects,
});
}
beginSubmission(agentId: string, message: UserMessageItem, tracked: boolean): void {
if (!this.accepts(agentId)) return;
const state = this.state(agentId);
if (tracked) {
if (!message.clientMessageId)
throw new Error("Beginning a message submission requires client identity");
state.submissions = beginMessageSubmission(state.submissions, {
clientMessageId: message.clientMessageId,
});
}
applySubmissionRows(state, (rows) => appendSubmittedUserMessage({ ...rows, message }));
this.publish(agentId, state);
}
acceptSubmission(agentId: string, clientMessageId: string): void {
if (!this.accepts(agentId)) return;
const state = this.state(agentId);
state.submissions = acceptMessageSubmission(state.submissions, clientMessageId);
this.publish(agentId, state);
}
rejectSubmission(
agentId: string,
clientMessageId: string,
tracked: boolean,
): MessageSubmissionRejectionOutcome {
if (!this.accepts(agentId)) return "unknown";
const state = this.state(agentId);
const rejection = rejectMessageSubmission(state.submissions, clientMessageId);
const outcome = tracked ? rejection.outcome : "rejected";
state.submissions = rejection.submissions;
if (outcome === "rejected") {
applySubmissionRows(state, (rows) =>
removeSubmittedUserMessage({ ...rows, clientMessageId }),
);
}
this.publish(agentId, state);
return outcome;
}
handoffSubmission(agentId: string, message: UserMessageItem): boolean {
if (!this.accepts(agentId)) return false;
const state = this.state(agentId);
const changed = applySubmissionRows(state, (rows) =>
handoffCreatedAgentUserMessageToStream({ ...rows, message }),
);
if (!changed) return false;
this.publish(agentId, state, {
persist: Boolean(state.verified),
requireCertified: Boolean(state.verified?.range),
});
return true;
}
acceptAgent(agentId: string): void {
if (!this.disposed) this.removed.delete(agentId);
}
remove(agentId: string, reason: AgentRemovalReason): void {
this.removed.add(agentId);
this.agents.delete(agentId);
if (reason === "deleted") this.storage.removeTimeline(this.serverId, agentId);
useSessionStore.getState().removeAgentTimeline(this.serverId, agentId);
}
reconcileServerId(serverId: string): void {
this.serverId = serverId;
for (const [agentId, state] of this.agents) {
this.publish(agentId, state, { synchronized: state.synchronized });
}
}
dispose(): void {
this.disposed = true;
this.agents.clear();
}
private publish(
agentId: string,
state: AgentTimeline,
options: { persist?: boolean; requireCertified?: boolean; synchronized?: boolean } = {},
): void {
const { persist = false, requireCertified = false, synchronized = false } = options;
// Unknown display content cannot certify disk, but does not revoke known source coverage.
// While positioned catch-up is incomplete, keep its accepted durable baseline instead.
const displayOnly = state.hasUnsequencedHistory && state.synchronized && !state.newer;
const verified = displayOnly ? null : state.verified;
const range = verified?.range ?? null;
const durable: CachedTimeline | null =
persist && (verified !== null || state.restored)
? {
agentId,
items: verified ? [...verified.tail, ...verified.head] : [...state.tail, ...state.head],
range,
hasOlder: Boolean(verified?.hasOlder),
}
: null;
// Both outputs are determined before subscribers run; none can authorize persistence by
// changing a mutable store flag between publication and the write-behind decision.
if (durable) {
const page = latestRestartPage(durable, state.restartStartSeq);
if (page.range) state.restartStartSeq = page.range.startSeq;
this.storage.commitTimeline(
this.serverId,
agentId,
page,
(requireCertified && !displayOnly) || !state.restored,
);
}
useSessionStore.getState().applyAgentTimelineResponseState(this.serverId, agentId, {
items: state.tail,
head: state.head,
range: state.displayRange,
older: state.older ? "available" : "none",
newer: state.newer,
synchronized,
submissions: state.submissions,
});
}
}
function latestRestartPage(
timeline: CachedTimeline,
restartStartSeq: number | null,
): CachedTimeline {
const startOf = (item: StreamItem | undefined) =>
item?.source?.startSeq ?? item?.timelineCursor?.seq;
const candidates = timeline.items.filter(
(item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item),
);
const items = candidates.filter(
(item) =>
restartStartSeq === null ||
(startOf(item) ?? restartStartSeq) >= restartStartSeq ||
// A prepend may complete the retained boundary message. Keep that whole item,
// without admitting older tools merely because they finish inside the window.
item.source?.chunks?.some((chunk) => chunk.seq === restartStartSeq),
);
const first = Math.max(0, items.length - TIMELINE_FETCH_PAGE_SIZE);
const omitted = candidates.length > items.length || first > 0;
const firstSource = startOf(items[first]);
const floor = restartStartSeq ?? timeline.range?.startSeq;
const startSeq = first > 0 ? firstSource : Math.min(floor ?? Infinity, firstSource ?? Infinity);
const range =
timeline.range && startSeq !== undefined && Number.isFinite(startSeq)
? { ...timeline.range, startSeq: Math.max(timeline.range.startSeq, startSeq) }
: timeline.range;
return {
...timeline,
items: items.slice(first),
range,
hasOlder:
timeline.hasOlder ||
omitted ||
Boolean(range && timeline.range && range.startSeq > timeline.range.startSeq),
};
}
export function createTimelineReplica(input: {
serverId: string;
storage: TimelineReplicaStorage;
}): TimelineReplica {
return new TimelineReplica(input.serverId, input.storage);
}
function retainPendingSubmissions(rows: TimelineRows, state: AgentTimeline): TimelineRows {
const pending = getSendingClientMessageIds(state.submissions);
for (const row of [...state.tail, ...state.head]) {
if (
row.kind !== "user_message" ||
!row.clientMessageId ||
!pending.includes(row.clientMessageId)
)
continue;
rows = handoffCreatedAgentUserMessageToStream({ ...rows, message: row });
}
return rows;
}
function reduceVerifiedPage(
state: AgentTimeline,
common: Omit<Parameters<typeof processTimelineResponse>[0], "currentTail" | "currentHead">,
display: ProcessTimelineResponseOutput,
): VerifiedTimeline {
const baseline = state.verified;
const currentCursor = baseline?.range ?? undefined;
const shared =
(baseline === null && state.tail.length === 0 && state.head.length === 0) ||
(baseline?.tail === state.tail && baseline.head === state.head);
let rows: TimelineRows = baseline ?? { tail: [], head: [] };
for (const row of shared ? [] : [...state.tail, ...state.head]) {
if (row.kind === "user_message" && isUnreconciledLocalUserMessage(row)) {
rows = handoffCreatedAgentUserMessageToStream({ ...rows, message: row });
}
}
const verified = shared
? display
: processTimelineResponse({
...common,
currentCursor,
currentTail: rows.tail,
currentHead: rows.head,
});
// Divergence ends when the accepted page reconciles the overlay. Subsequent events share
// one reduction again; no consumer or synchronization flag decides row identity.
if (!shared && equal([...verified.tail, ...verified.head], [...display.tail, ...display.head])) {
verified.tail = display.tail;
verified.head = display.head;
}
const range = verified.cursorChanged ? (verified.cursor ?? null) : (currentCursor ?? null);
return {
tail: verified.tail,
head: verified.head,
range,
hasOlder: verified.older === "unchanged" ? state.older : verified.older === "available",
};
}
function recoverDisplayedGap(
agentId: string,
state: AgentTimeline,
recoverGap: PageEffects["recoverGap"],
): void {
const range = state.verified?.range;
if (!range) return;
for (const row of [...state.tail, ...state.head]) {
const cursor = row.timelineCursor;
if (cursor?.epoch === range.epoch && cursor.seq > range.endSeq)
recoverGap(agentId, range, cursor.seq);
}
}
function clearAgentInitializingFlag(serverId: string, agentId: string): void {
useSessionStore.getState().setInitializingAgents(serverId, (previous) => {
if (previous.get(agentId) !== true) return previous;
const next = new Map(previous);
next.set(agentId, false);
return next;
});
}
function settleTimelineInitialization(
serverId: string,
agentId: string,
result: Pick<ProcessTimelineResponseOutput, "clearInitializing" | "initResolution" | "error">,
): void {
if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId);
const key = getInitKey(serverId, agentId);
if (result.initResolution === "resolve") resolveInitDeferred(key);
if (result.initResolution === "reject" && result.error)
rejectInitDeferred(key, new Error(result.error));
}
function finalizeProcessedTimeline(input: {
serverId: string;
agentId: string;
result: ProcessTimelineResponseOutput;
synchronized: boolean;
recoverGap: (
agentId: string,
cursor: { epoch: string; endSeq: number },
observedSeq?: number,
) => void;
drainQueuedAgentMessage: (agentId: string) => void;
}): void {
for (const effect of input.result.sideEffects) {
if (effect.type === "catch_up") {
input.recoverGap(input.agentId, effect.cursor, effect.observedSeq);
}
}
settleTimelineInitialization(input.serverId, input.agentId, input.result);
if (input.synchronized) {
useCreateFlowStore
.getState()
.clearByAgent({ serverId: input.serverId, agentId: input.agentId });
const session = useSessionStore.getState().sessions[input.serverId];
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId);
}
}
@@ -4,7 +4,6 @@ import type { AgentTimelineItem, ToolCallDetail } from "@getpaseo/protocol/agent
import {
createUserMessage,
hydrateStreamState,
rowStartSeq,
type AgentToolCallItem,
type StreamItem,
} from "@/types/stream";
@@ -482,12 +481,7 @@ describe("processTimelineResponse", () => {
it("replaces destructively when the same epoch rewinds behind the local cursor", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [
{
...makeAssistantItem("future history", "future-history"),
timelineCursor: { epoch: "epoch-1", seq: 40 },
},
],
currentTail: [makeAssistantItem("future history", "future-history")],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 },
payload: {
...baseTimelineInput.payload,
@@ -499,37 +493,10 @@ describe("processTimelineResponse", () => {
},
});
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["rewound history"]);
expect(getAssistantTexts(result.tail)).toEqual(["rewound history"]);
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 10 });
});
it("drops superseded positioned rows when a forced reset tail ends behind the cursor", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [
{
...makeAssistantItem("future history", "future-history"),
timelineCursor: { epoch: "epoch-1", seq: 40 },
},
],
currentHead: [makeSubmittedUserMessage("still sending", "sending-prompt")],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 },
sendingClientMessageIds: ["sending-prompt"],
payload: {
...baseTimelineInput.payload,
direction: "tail",
reset: true,
window: { minSeq: 1, maxSeq: 10, nextSeq: 11 },
startCursor: { seq: 1 },
endCursor: { seq: 10 },
entries: [makeTimelineEntry(10, "rewound history")],
},
});
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["rewound history"]);
expect(getUserTexts([...result.tail, ...result.head])).toEqual(["still sending"]);
});
it("preserves the canonical end cursor on a projected assistant message", () => {
const result = processTimelineResponse({
...baseTimelineInput,
@@ -991,13 +958,6 @@ describe("processTimelineResponse", () => {
text: "canonical prefix continuation",
timestamp: new Date(2001),
timelineCursor: { epoch: "epoch-1", seq: 101 },
source: {
startSeq: 100,
chunks: [
{ seq: 100, offset: 0 },
{ seq: 101, offset: "canonical prefix".length },
],
},
},
],
isInitializing: true,
@@ -3078,7 +3038,7 @@ describe("processTimelineResponse", () => {
})),
).toEqual([
{
id: "assistant-message",
id: "loaded-assistant",
text: "projected response",
messageId: "assistant-message",
timelineCursor: { epoch: "epoch-1", seq: 500 },
@@ -3237,57 +3197,6 @@ describe("processTimelineResponse", () => {
});
});
it.each([
["older", "newer", ["older chunk ", "newer chunk"]],
["same", "same", ["older chunk newer chunk"]],
[undefined, "newer", ["older chunk newer chunk"]],
["older", undefined, ["older chunk newer chunk"]],
])("preserves assistant identity at a prepend boundary (%s, %s)", (olderId, newerId, texts) => {
const older = makeTimelineEntry(1, "older chunk ");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [
{
...makeAssistantItem("newer chunk", "newer"),
messageId: newerId,
timelineCursor: { epoch: "epoch-1", seq: 2 },
source: { startSeq: 2, chunks: [{ seq: 2, offset: 0 }] },
},
],
currentCursor: { epoch: "epoch-1", startSeq: 2, endSeq: 2 },
payload: {
...baseTimelineInput.payload,
direction: "before",
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 1 },
entries: [
{
...older,
item: { type: "assistant_message", text: "older chunk ", messageId: olderId },
},
],
},
});
expect(getAssistantTexts(result.tail)).toEqual(texts);
expect(result.tail.map((row) => row.source)).toEqual(
texts.length === 2
? [
{ startSeq: 1, chunks: [{ seq: 1, offset: 0 }] },
{ startSeq: 2, chunks: [{ seq: 2, offset: 0 }] },
]
: [
{
startSeq: 1,
chunks: [
{ seq: 1, offset: 0 },
{ seq: 2, offset: 12 },
],
},
],
);
});
it("coalesces tool call lifecycle rows across the older-page prepend boundary", () => {
const callId = "toolu_boundary";
const turnId = "turn-boundary";
@@ -4437,13 +4346,12 @@ describe("createAgentStreamReducerQueue", () => {
let currentHead: StreamItem[] = [];
const queue = createAgentStreamReducerQueue({
apply: (agentId, events) => {
const result = processAgentStreamEvents({
events,
currentTail,
currentHead,
currentCursor: undefined,
});
getSnapshot: () => ({
currentTail,
currentHead,
currentCursor: undefined,
}),
commit: (agentId, result) => {
currentTail = result.tail;
currentHead = result.head;
commits.push({
@@ -4452,6 +4360,7 @@ describe("createAgentStreamReducerQueue", () => {
cursorEndSeq: result.cursor?.endSeq ?? null,
});
},
handleSideEffects: () => {},
scheduleFlush: scheduler.schedule,
cancelFlush: scheduler.cancel,
});
@@ -4478,17 +4387,17 @@ describe("createAgentStreamReducerQueue", () => {
const scheduler = createManualScheduler();
const commits: string[] = [];
const queue = createAgentStreamReducerQueue({
apply: (agentId, events) => {
const result = processAgentStreamEvents({
events,
currentTail: [],
currentHead: [],
currentCursor: undefined,
});
getSnapshot: () => ({
currentTail: [],
currentHead: [],
currentCursor: undefined,
}),
commit: (agentId, result) => {
commits.push(
`${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`,
);
},
handleSideEffects: () => {},
scheduleFlush: scheduler.schedule,
cancelFlush: scheduler.cancel,
});
@@ -4507,17 +4416,17 @@ describe("createAgentStreamReducerQueue", () => {
let currentCursor: TimelineCursor | undefined;
const queue = createAgentStreamReducerQueue({
apply: (_agentId, events) => {
const result = processAgentStreamEvents({
events,
currentTail,
currentHead,
currentCursor,
});
getSnapshot: () => ({
currentTail,
currentHead,
currentCursor,
}),
commit: (_agentId, result) => {
currentTail = result.tail;
currentHead = result.head;
currentCursor = result.cursor ?? undefined;
},
handleSideEffects: () => {},
scheduleFlush: scheduler.schedule,
cancelFlush: scheduler.cancel,
});
@@ -4556,17 +4465,17 @@ describe("createAgentStreamReducerQueue", () => {
const scheduler = createManualScheduler();
const commits: string[] = [];
const queue = createAgentStreamReducerQueue({
apply: (agentId, events) => {
const result = processAgentStreamEvents({
events,
currentTail: [],
currentHead: [],
currentCursor: undefined,
});
getSnapshot: () => ({
currentTail: [],
currentHead: [],
currentCursor: undefined,
}),
commit: (agentId, result) => {
commits.push(
`${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`,
);
},
handleSideEffects: () => {},
scheduleFlush: scheduler.schedule,
cancelFlush: scheduler.cancel,
});
@@ -4578,706 +4487,3 @@ describe("createAgentStreamReducerQueue", () => {
expect(scheduler.size).toBe(0);
});
});
describe("forward pages reconcile live rows by identity and source position", () => {
const detail: ToolCallDetail = { type: "read", filePath: "/tmp/example.ts" };
const checkpoint = (n: number) => `\n\n---\n\nReplica QA checkpoint ${n}: saved history`;
function liveBeyondBootstrap() {
return processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeAssistantTimelineEvent("\n\n---\n\n", "msg-102"), 675),
makeStreamReducerEvent(
makeAssistantTimelineEvent("Replica QA checkpoint 102: saved history", "msg-102"),
679,
),
makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 681),
makeStreamReducerEvent(makeAssistantTimelineEvent("\n\n---\n\n", "msg-103"), 682),
makeStreamReducerEvent(
makeAssistantTimelineEvent("Replica QA checkpoint 103: saved history", "msg-103"),
685,
),
makeStreamReducerEvent(makeToolCallTimelineEvent("exec-2"), 687),
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
}
function projectRows(items: StreamItem[]) {
return items.map((item) =>
item.kind === "assistant_message"
? { kind: item.kind, messageId: item.messageId, text: item.text }
: { kind: item.kind, id: item.id },
);
}
it("replaces split live blocks wholly beyond the cursor instead of appending them again", () => {
const live = liveBeyondBootstrap();
expect(getAssistantTexts(live.head)).toHaveLength(4);
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [
{
...makeAssistantItem(checkpoint(101), "msg-101"),
messageId: "msg-101",
timelineCursor: { epoch: "epoch-1", seq: 672 },
},
],
currentHead: live.head,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
epoch: "epoch-1",
startCursor: { seq: 675 },
endCursor: { seq: 691 },
entries: [
{
...makeTimelineEntry(675, checkpoint(102), "assistant_message", 679),
sourceSeqRanges: [{ startSeq: 675, endSeq: 679 }],
item: { type: "assistant_message", text: checkpoint(102), messageId: "msg-102" },
},
makeToolCallTimelineEntry(681, "exec-1", "completed", detail),
{
...makeTimelineEntry(682, checkpoint(103), "assistant_message", 685),
sourceSeqRanges: [{ startSeq: 682, endSeq: 685 }],
item: { type: "assistant_message", text: checkpoint(103), messageId: "msg-103" },
},
makeToolCallTimelineEntry(687, "exec-2", "completed", detail),
{
...makeTimelineEntry(691, checkpoint(104)),
item: { type: "assistant_message", text: checkpoint(104), messageId: "msg-104" },
},
],
},
});
const rows = [...result.tail, ...result.head];
expect(projectRows(rows)).toEqual([
{ kind: "assistant_message", messageId: "msg-101", text: checkpoint(101) },
{ kind: "assistant_message", messageId: "msg-102", text: checkpoint(102) },
{ kind: "tool_call", id: "agent_tool_exec-1" },
{ kind: "assistant_message", messageId: "msg-103", text: checkpoint(103) },
{ kind: "tool_call", id: "agent_tool_exec-2" },
{ kind: "assistant_message", messageId: "msg-104", text: checkpoint(104) },
]);
expect(new Set(rows.map((item) => item.id)).size).toBe(rows.length);
expect(getAgentToolCalls(rows).map((item) => item.payload.data.status)).toEqual([
"completed",
"completed",
]);
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 600, endSeq: 691 });
});
it.each(["positioned", "unpositioned", "repeated"] as const)(
"preserves same-message segments around a tool (%s)",
(delivery) => {
const beforeText = delivery === "repeated" ? "Repeat" : "Before tool";
const afterText = delivery === "repeated" ? "Repeat" : "After tool";
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeAssistantTimelineEvent(beforeText, "msg-a"), 675),
makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676),
makeStreamReducerEvent(makeAssistantTimelineEvent(afterText, "msg-a"), 677),
].map((event) =>
delivery === "positioned" || (delivery === "repeated" && event.seq === 676)
? event
: Object.assign(event, { seq: undefined, epoch: undefined }),
),
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: live.tail,
currentHead: live.head,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
window: { minSeq: 1, maxSeq: 677, nextSeq: 678 },
startCursor: { seq: 675 },
endCursor: { seq: 677 },
entries: [
{
...makeTimelineEntry(675, beforeText),
item: { type: "assistant_message", text: beforeText, messageId: "msg-a" },
},
makeToolCallTimelineEntry(676, "exec-1", "running", detail),
{
...makeTimelineEntry(677, afterText),
item: { type: "assistant_message", text: afterText, messageId: "msg-a" },
},
],
},
});
const ids = [...result.tail, ...result.head].map((row) => row.id);
expect(new Set(ids).size).toBe(ids.length);
expect(projectRows([...result.tail, ...result.head])).toEqual([
{ kind: "assistant_message", messageId: "msg-a", text: beforeText },
{ kind: "tool_call", id: "agent_tool_exec-1" },
{ kind: "assistant_message", messageId: "msg-a", text: afterText },
]);
},
);
it("records each unit's first source position on bootstrap", () => {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
payload: {
...baseTimelineInput.payload,
direction: "tail",
reset: true,
window: { minSeq: 1, maxSeq: 700, nextSeq: 701 },
startCursor: { seq: 675 },
endCursor: { seq: 700 },
entries: [
{
...makeTimelineEntry(675, "Before tool"),
item: { type: "assistant_message", text: "Before tool", messageId: "msg-a" },
},
{
...makeToolCallTimelineEntry(676, "exec-1", "completed", detail),
seqEnd: 700,
sourceSeqRanges: [
{ startSeq: 676, endSeq: 676 },
{ startSeq: 700, endSeq: 700 },
],
collapsed: ["tool_lifecycle"],
},
{
...makeTimelineEntry(677, "After tool"),
item: { type: "assistant_message", text: "After tool", messageId: "msg-a" },
},
],
},
});
const rows = [...result.tail, ...result.head];
expect(rows.map((row) => row.kind)).toEqual([
"assistant_message",
"tool_call",
"assistant_message",
]);
expect(rows.map(rowStartSeq)).toEqual([675, 676, 677]);
});
it("keeps parallel tools in the order they started when their completions are reversed", () => {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
payload: {
...baseTimelineInput.payload,
direction: "tail",
reset: true,
window: { minSeq: 1, maxSeq: 700, nextSeq: 701 },
startCursor: { seq: 676 },
endCursor: { seq: 700 },
entries: [
{
...makeToolCallTimelineEntry(676, "first", "completed", detail),
seqEnd: 700,
sourceSeqRanges: [
{ startSeq: 676, endSeq: 676 },
{ startSeq: 700, endSeq: 700 },
],
collapsed: ["tool_lifecycle"],
},
{
...makeToolCallTimelineEntry(677, "second", "completed", detail),
seqEnd: 690,
sourceSeqRanges: [
{ startSeq: 677, endSeq: 677 },
{ startSeq: 690, endSeq: 690 },
],
collapsed: ["tool_lifecycle"],
},
],
},
});
const tools = getAgentToolCalls([...result.tail, ...result.head]);
expect(tools.map((tool) => tool.payload.data.callId)).toEqual(["first", "second"]);
expect(tools.map(rowStartSeq)).toEqual([676, 677]);
expect(tools.map((tool) => tool.timelineCursor?.seq)).toEqual([700, 690]);
});
it("keeps an interleaved tool in place when the page arrives with no live rows", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
window: { minSeq: 1, maxSeq: 700, nextSeq: 701 },
startCursor: { seq: 675 },
endCursor: { seq: 700 },
entries: [
{
...makeTimelineEntry(675, "Before tool"),
item: { type: "assistant_message", text: "Before tool", messageId: "msg-a" },
},
{
...makeToolCallTimelineEntry(676, "exec-1", "completed", detail),
seqEnd: 700,
sourceSeqRanges: [
{ startSeq: 676, endSeq: 676 },
{ startSeq: 700, endSeq: 700 },
],
collapsed: ["tool_lifecycle"],
},
{
...makeTimelineEntry(677, "After tool"),
item: { type: "assistant_message", text: "After tool", messageId: "msg-a" },
},
],
},
});
const rows = [...result.tail, ...result.head];
expect(projectRows(rows)).toEqual([
{ kind: "assistant_message", messageId: "msg-a", text: "Before tool" },
{ kind: "tool_call", id: "agent_tool_exec-1" },
{ kind: "assistant_message", messageId: "msg-a", text: "After tool" },
]);
expect(rows.map(rowStartSeq)).toEqual([675, 676, 677]);
});
it("keeps an interleaved tool whose completion is newer than text on both sides", () => {
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeAssistantTimelineEvent("Before tool", "msg-a"), 675),
makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676),
makeStreamReducerEvent(makeAssistantTimelineEvent("After tool", "msg-a"), 677),
makeStreamReducerEvent(
{
...makeToolCallTimelineEvent("exec-1"),
item: makeToolCallTimelineEntry(676, "exec-1", "completed", detail).item,
} as AgentStreamEventPayload,
700,
),
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: live.tail,
currentHead: live.head,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
window: { minSeq: 1, maxSeq: 700, nextSeq: 701 },
startCursor: { seq: 675 },
endCursor: { seq: 700 },
entries: [
{
...makeTimelineEntry(675, "Before tool"),
item: { type: "assistant_message", text: "Before tool", messageId: "msg-a" },
},
{
...makeToolCallTimelineEntry(676, "exec-1", "completed", detail),
seqEnd: 700,
sourceSeqRanges: [
{ startSeq: 676, endSeq: 676 },
{ startSeq: 700, endSeq: 700 },
],
collapsed: ["tool_lifecycle"],
},
{
...makeTimelineEntry(677, "After tool"),
item: { type: "assistant_message", text: "After tool", messageId: "msg-a" },
},
],
},
});
const rows = [...result.tail, ...result.head];
expect(projectRows(rows)).toEqual([
{ kind: "assistant_message", messageId: "msg-a", text: "Before tool" },
{ kind: "tool_call", id: "agent_tool_exec-1" },
{ kind: "assistant_message", messageId: "msg-a", text: "After tool" },
]);
expect(getAgentToolCalls(rows)[0]?.payload.data.status).toBe("completed");
});
it("keeps a tool call at the position it appeared even when its completion is newer", () => {
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676),
makeStreamReducerEvent(makeAssistantTimelineEvent("While the tool runs", "msg-a"), 680),
makeStreamReducerEvent(
{
...makeToolCallTimelineEvent("exec-1"),
item: { ...makeToolCallTimelineEntry(676, "exec-1", "completed", detail).item },
} as AgentStreamEventPayload,
700,
),
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: live.head,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
window: { minSeq: 1, maxSeq: 700, nextSeq: 701 },
startCursor: { seq: 675 },
endCursor: { seq: 700 },
entries: [
{
...makeToolCallTimelineEntry(676, "exec-1", "completed", detail),
seqEnd: 700,
sourceSeqRanges: [
{ startSeq: 676, endSeq: 676 },
{ startSeq: 700, endSeq: 700 },
],
collapsed: ["tool_lifecycle"],
},
{
...makeTimelineEntry(680, "While the tool runs"),
item: { type: "assistant_message", text: "While the tool runs", messageId: "msg-a" },
},
],
},
});
expect(projectRows([...result.tail, ...result.head])).toEqual([
{ kind: "tool_call", id: "agent_tool_exec-1" },
{ kind: "assistant_message", messageId: "msg-a", text: "While the tool runs" },
]);
expect(getAgentToolCalls([...result.tail, ...result.head])[0]?.payload.data.status).toBe(
"completed",
);
});
it("does not duplicate a notification the page delivers again", () => {
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(
{
type: "timeline",
provider: "claude",
item: { type: "error", message: "Rate limited" },
} as AgentStreamEventPayload,
680,
),
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: live.head,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
window: { minSeq: 1, maxSeq: 680, nextSeq: 681 },
startCursor: { seq: 675 },
endCursor: { seq: 680 },
entries: [
{
...makeTimelineEntry(680, "Rate limited"),
item: { type: "error", message: "Rate limited" },
},
],
},
});
const notifications = [...result.tail, ...result.head].filter(
(item) => item.kind === "notification",
);
expect(notifications).toHaveLength(1);
expect(notifications[0]?.id).toBe(live.head[0]?.id);
});
it("inserts missed canonical history before newer live messages during reconnect", () => {
const live = processAgentStreamEvents({
events: [makeStreamReducerEvent(makeAssistantTimelineEvent("Arrived live", "msg-new"), 700)],
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [],
currentHead: live.head,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
window: { minSeq: 1, maxSeq: 690, nextSeq: 691 },
startCursor: { seq: 675 },
endCursor: { seq: 690 },
hasNewer: false,
entries: [
{
...makeTimelineEntry(675, "Missed while closed", "assistant_message", 679),
item: { type: "assistant_message", text: "Missed while closed", messageId: "msg-old" },
},
],
},
});
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([
"Missed while closed",
"Arrived live",
]);
});
it.each([
{
name: "partially overlaps the canonical prefix",
events: [
makeStreamReducerEvent(makeAssistantTimelineEvent("llo", "msg-a"), 680),
makeStreamReducerEvent(makeAssistantTimelineEvent(" world", "msg-a"), 700),
],
canonical: "Hello",
expected: "Hello world",
},
{
name: "repeats the prefix after its coverage",
events: [makeStreamReducerEvent(makeAssistantTimelineEvent("Ha", "msg-a"), 700)],
canonical: "Ha",
expected: "HaHa",
},
])(
"reconciles by source coverage when newer live text $name",
({ events, canonical, expected }) => {
const live = processAgentStreamEvents({
events,
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: live.head,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
window: { minSeq: 1, maxSeq: 690, nextSeq: 691 },
startCursor: { seq: 675 },
endCursor: { seq: 690 },
hasNewer: false,
entries: [
{
...makeTimelineEntry(675, canonical, "assistant_message", 690),
item: { type: "assistant_message", text: canonical, messageId: "msg-a" },
},
],
},
});
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([expected]);
},
);
it("fills a missing prefix when a lagging final page meets a newer live suffix", () => {
const live = processAgentStreamEvents({
events: [makeStreamReducerEvent(makeAssistantTimelineEvent(" world", "msg-a"), 700)],
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: live.head,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
startCursor: { seq: 675 },
endCursor: { seq: 690 },
hasNewer: false,
entries: [
{
...makeTimelineEntry(675, "Hello", "assistant_message", 690),
item: { type: "assistant_message", text: "Hello", messageId: "msg-a" },
},
],
},
});
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["Hello world"]);
});
it("prefixes only the first block of a split live suffix", () => {
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeAssistantTimelineEvent(" world.\n\n", "msg-a"), 700),
makeStreamReducerEvent(makeAssistantTimelineEvent("Second paragraph", "msg-a"), 701),
],
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
expect(getAssistantTexts(live.head)).toEqual([" world.", "Second paragraph"]);
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: live.head,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
startCursor: { seq: 675 },
endCursor: { seq: 690 },
hasNewer: false,
entries: [
{
...makeTimelineEntry(675, "Hello", "assistant_message", 690),
item: { type: "assistant_message", text: "Hello", messageId: "msg-a" },
},
],
},
});
const rows = [...result.tail, ...result.head].filter(
(item) => item.kind === "assistant_message",
);
expect(rows.map((item) => item.text)).toEqual(["Hello world.", "Second paragraph"]);
expect(rows.map((item) => item.id)).toEqual(live.head.map((item) => item.id));
expect(rows.every((item) => item.messageId === "msg-a")).toBe(true);
});
it.each([true, false])("keeps newer text beyond a lagging page (positioned=%s)", (positioned) => {
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeAssistantTimelineEvent("Hello", "msg-a"), 690),
makeStreamReducerEvent(makeAssistantTimelineEvent(" world", "msg-a"), 700),
].map((event) =>
positioned ? event : Object.assign(event, { seq: undefined, epoch: undefined }),
),
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: live.head,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
epoch: "epoch-1",
startCursor: { seq: 675 },
endCursor: { seq: 690 },
hasNewer: true,
entries: [
{
...makeTimelineEntry(690, "Hello"),
item: { type: "assistant_message", text: "Hello", messageId: "msg-a" },
},
],
},
});
const assistants = [...result.tail, ...result.head].filter(
(item) => item.kind === "assistant_message",
);
expect(assistants).toHaveLength(1);
expect(assistants[0]).toMatchObject({
messageId: "msg-a",
text: "Hello world",
});
expect(assistants[0]?.timelineCursor).toEqual(
positioned ? { epoch: "epoch-1", seq: 700 } : undefined,
);
});
it.each([true, false])(
"keeps distinct identities with identical text (positioned=%s)",
(positioned) => {
const live = processAgentStreamEvents({
events: [makeStreamReducerEvent(makeAssistantTimelineEvent("Done.", "msg-a"), 680)].map(
(event) =>
positioned ? event : Object.assign(event, { seq: undefined, epoch: undefined }),
),
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: live.head,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
epoch: "epoch-1",
startCursor: { seq: 675 },
endCursor: { seq: 681 },
entries: [
{
...makeTimelineEntry(681, "Done."),
item: { type: "assistant_message", text: "Done.", messageId: "msg-b" },
},
],
},
});
expect(
[...result.tail, ...result.head]
.filter((item) => item.kind === "assistant_message")
.map((item) => item.messageId),
).toEqual(["msg-a", "msg-b"]);
},
);
it("does not guess which identical unpositioned segment a partial page covers", () => {
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeAssistantTimelineEvent("Repeat", "msg-a"), 675),
makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676),
makeStreamReducerEvent(makeAssistantTimelineEvent("Repeat", "msg-a"), 677),
].map((event) => Object.assign(event, { seq: undefined, epoch: undefined })),
currentTail: [],
currentHead: [],
currentCursor: undefined,
hasAuthoritativeBaseline: false,
});
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: live.tail,
currentHead: live.head,
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
payload: {
...baseTimelineInput.payload,
direction: "after",
startCursor: { seq: 675 },
endCursor: { seq: 675 },
entries: [
{
...makeTimelineEntry(675, "Repeat"),
item: { type: "assistant_message", text: "Repeat", messageId: "msg-a" },
},
],
},
});
const rows = [...result.tail, ...result.head];
expect(rows.filter((row) => !row.timelineCursor)).toEqual([...live.tail, ...live.head]);
expect(rows.filter((row) => row.timelineCursor).map((row) => rowStartSeq(row))).toEqual([675]);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,10 @@
import { expect, test, vi } from "vitest";
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
import { createViewedTimelineSync } from "./viewed-timeline-sync";
import {
consumeForcedTimelineTailReplacement,
createViewedTimelineSync,
type TimelineResponsePayload,
} from "./viewed-timeline-sync";
interface Deferred<T> {
promise: Promise<T>;
@@ -27,7 +31,7 @@ interface MembershipRequest {
interface TimelineFetch {
agentId: string;
request: ProjectedTimelineForwardFetchPlan;
respond(input: { hasNewer: boolean; seq?: number | null }): void;
respond(input: { hasNewer: boolean; seq?: number }): void;
fail(message: string): void;
}
@@ -35,6 +39,7 @@ class TimelineWorld {
readonly errors: string[] = [];
readonly cursors = new Map<string, { epoch: string; endSeq: number }>();
readonly cacheRequests: string[] = [];
readonly forcedTimelineTailReplacements = new Set<string>();
cacheGate: Deferred<void> | null = null;
readonly sync = createViewedTimelineSync({
replaceDemandedAgentIds: () => undefined,
@@ -66,7 +71,7 @@ class TimelineWorld {
respond: ({ hasNewer, seq = 1 }) =>
result.resolve({
hasNewer,
endCursor: seq === null ? null : { epoch: `epoch-${agentId}`, seq },
endCursor: { epoch: `epoch-${agentId}`, seq },
}),
fail: (message) => result.reject(new Error(message)),
});
@@ -74,11 +79,16 @@ class TimelineWorld {
return result.promise;
},
fetchLatestTail: async (agentId) => {
return await this.fetchTimeline(agentId, {
direction: "tail",
limit: 40,
projection: "projected",
});
this.forcedTimelineTailReplacements.add(agentId);
try {
return await this.fetchTimeline(agentId, {
direction: "tail",
limit: 40,
projection: "projected",
});
} finally {
this.forcedTimelineTailReplacements.delete(agentId);
}
},
reportError: (error) => {
this.errors.push(error instanceof Error ? error.message : String(error));
@@ -120,6 +130,10 @@ class TimelineWorld {
return this.fetches.length;
}
applyTimelineResponse(payload: TimelineResponsePayload): TimelineResponsePayload {
return consumeForcedTimelineTailReplacement(payload, this.forcedTimelineTailReplacements);
}
nextCacheRequest(): Promise<string> {
const request = this.cacheRequests.at(-1);
if (request) return Promise.resolve(request);
@@ -138,10 +152,7 @@ class TimelineWorld {
agentId,
request,
respond: ({ hasNewer, seq = 1 }) =>
result.resolve({
hasNewer,
endCursor: seq === null ? null : { epoch: `epoch-${agentId}`, seq },
}),
result.resolve({ hasNewer, endCursor: { epoch: `epoch-${agentId}`, seq } }),
fail: (message) => result.reject(new Error(message)),
});
this.releaseFetchWaiters();
@@ -211,7 +222,7 @@ class TimelineWorld {
test("uses a tail fetch when an agent becomes visible", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
@@ -224,7 +235,7 @@ test("loads the cache before choosing the authoritative network request", async
const world = new TimelineWorld();
world.cacheGate = deferred<void>();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const cacheRequest = await world.nextCacheRequest();
@@ -245,22 +256,11 @@ test("loads the cache before choosing the authoritative network request", async
fetch.respond({ hasNewer: false });
});
test("a fresh visible agent lifetime performs a fresh cache preparation", async () => {
const world = new TimelineWorld();
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
await vi.waitFor(() => expect(world.cacheRequests).toEqual(["agent-a"]));
world.sync.endAgentLifetime("agent-a");
world.sync.beginAgentLifetime("agent-a");
await vi.waitFor(() => expect(world.cacheRequests).toEqual(["agent-a", "agent-a"]));
});
test("catches up after the restored cursor when an agent becomes visible", async () => {
const world = new TimelineWorld();
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
@@ -278,7 +278,7 @@ test("falls back to the latest tail when a restored cursor has more than one cat
const world = new TimelineWorld();
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
@@ -288,168 +288,63 @@ test("falls back to the latest tail when a restored cursor has more than one cat
const fallback = await world.nextFetch("agent-a");
expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" });
expect(
world.applyTimelineResponse({
requestId: "fallback-tail",
agentId: "agent-a",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-agent-a",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 43, maxSeq: 82, nextSeq: 83 },
startCursor: { epoch: "epoch-agent-a", seq: 43 },
endCursor: { epoch: "epoch-agent-a", seq: 82 },
hasOlder: true,
hasNewer: false,
entries: [],
error: null,
}).reset,
).toBe(true);
fallback.respond({ hasNewer: false });
});
test("a gap absorbed by a running tail is recovered after the tail completes", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const tail = await world.nextFetch("agent-a");
// A live row at 12 could not be applied while the tail was in flight.
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 }, 12);
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 });
world.expectNoPendingFetch();
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
tail.respond({ hasNewer: false, seq: 10 });
tail.respond({ hasNewer: false });
// The tail settled coverage at 10; the owner continues from there, not from the stale gap cursor.
const recovery = await world.nextFetch("agent-a");
expect(recovery.request).toEqual({
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 10 },
cursor: { epoch: "epoch-agent-a", seq: 9 },
limit: 40,
projection: "projected",
});
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 12 });
recovery.respond({ hasNewer: false, seq: 12 });
recovery.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.expectNoPendingFetch();
});
test("a page that predates observed activity does not complete the catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const initial = await world.nextFetch("agent-a");
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 }, 15);
const page = await world.nextFetch("agent-a");
expect(page.request).toMatchObject({ direction: "after", cursor: { seq: 10 } });
// More live rows arrive while that page is in transit; the page was built before them.
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20);
world.expectNoPendingFetch();
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 15 });
page.respond({ hasNewer: false, seq: 15 });
const rest = await world.nextFetch("agent-a");
expect(rest.request).toMatchObject({ direction: "after", cursor: { seq: 15 } });
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 20 });
rest.respond({ hasNewer: false, seq: 20 });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.expectNoPendingFetch();
});
test.each([null, 10])(
"activity learned during a non-advancing request is fetched immediately without a sync error (end=%s)",
async (end) => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
const initial = await world.nextFetch("agent-a");
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 }, 15);
const old = await world.nextFetch("agent-a");
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 25);
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20);
old.respond({ hasNewer: false, seq: end });
const fresh = await world.nextFetch("agent-a");
expect(world.errors).toEqual([]);
expect(fresh.request).toMatchObject({ direction: "after", cursor: { seq: 10 } });
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 25 });
fresh.respond({ hasNewer: false, seq: 25 });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.expectNoPendingFetch();
world.sync.dispose();
},
);
test("no progress against an obligation already known at request start retains it with bounded backoff", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const initial = await world.nextFetch("agent-a");
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 }, 20);
const page = await world.nextFetch("agent-a");
page.respond({ hasNewer: false, seq: 10 });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"));
expect(world.errors).toEqual([
"Timeline coverage for agent-a has not reached observed sequence 20",
]);
world.expectNoPendingFetch();
const retry = await world.nextRetry(1000);
retry();
const fresh = await world.nextFetch("agent-a");
expect(fresh.request).toMatchObject({ direction: "after", cursor: { seq: 10 } });
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 20 });
fresh.respond({ hasNewer: false, seq: 20 });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.dispose();
});
test("a daemon claiming another page without advancing cannot cause a busy loop", async () => {
const world = new TimelineWorld();
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
const first = await world.nextFetch("agent-a");
first.respond({ hasNewer: true, seq: 10 });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"));
expect(world.errors).toEqual(["Timeline page for agent-a did not advance its cursor"]);
world.expectNoPendingFetch();
await world.nextRetry(1000);
world.sync.dispose();
});
test("an observed head from a previous epoch does not extend a catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const initial = await world.nextFetch("agent-a");
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 }, 20);
const page = await world.nextFetch("agent-a");
world.cursors.set("agent-a", { epoch: "epoch-next", endSeq: 3 });
page.respond({ hasNewer: false, seq: 3 });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.expectNoPendingFetch();
});
test("unchanged visible-set publication does not cancel paged catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
const membership = await world.nextMembership();
membership.succeed();
const firstPage = await world.nextFetch("agent-a");
world.sync.registerVisibleAgentIds("workspace", ["agent-a", "agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]);
firstPage.respond({ hasNewer: true, seq: 5 });
const secondPage = await world.nextFetch("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
@@ -471,7 +366,7 @@ test("unchanged visible-set publication does not cancel paged catch-up", async (
test("all acknowledged agents begin catch-up independently", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-b", "agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b", "agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
@@ -488,7 +383,7 @@ test("all acknowledged agents begin catch-up independently", async () => {
test("an eviction during acknowledgement never catches up the stale hot set", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", [
world.sync.replaceVisibleAgentIds("workspace", [
"agent-a",
"agent-b",
"agent-c",
@@ -496,7 +391,7 @@ test("an eviction during acknowledgement never catches up the stale hot set", as
"agent-e",
]);
const staleMembership = await world.nextMembership();
world.sync.registerVisibleAgentIds("workspace", ["agent-f"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]);
staleMembership.succeed();
const currentMembership = await world.nextMembership();
currentMembership.succeed();
@@ -517,7 +412,7 @@ test("an eviction during acknowledgement never catches up the stale hot set", as
test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const firstMembership = await world.nextMembership();
firstMembership.succeed();
const stalePage = await world.nextFetch("agent-a");
@@ -539,8 +434,8 @@ test("disconnect cancels paging and reconnect restores membership before fresh c
test("navigation while disconnected reconnects only the currently visible agent", async () => {
const world = new TimelineWorld();
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
world.sync.setConnected(true);
const membership = await world.nextMembership();
@@ -554,8 +449,8 @@ test("navigation while disconnected reconnects only the currently visible agent"
test("overlapping sources deduplicate membership and retain hidden hot agents", async () => {
const world = new TimelineWorld();
world.sync.registerVisibleAgentIds("left-route", ["agent-a"]);
world.sync.registerVisibleAgentIds("right-route", ["agent-a", "agent-b"]);
world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]);
world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]);
world.sync.setConnected(true);
const combined = await world.nextMembership();
combined.succeed();
@@ -566,37 +461,19 @@ test("overlapping sources deduplicate membership and retain hidden hot agents",
agentA.respond({ hasNewer: false });
agentB.respond({ hasNewer: false });
world.sync.registerVisibleAgentIds("left-route", []);
world.sync.replaceVisibleAgentIds("left-route", []);
world.expectNoPendingMembership();
world.sync.registerVisibleAgentIds("right-route", ["agent-b"]);
world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]);
expect(combined.agentIds).toEqual(["agent-a", "agent-b"]);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
});
test("a replacement screen declaration survives obsolete cleanup and clears on final unmount", async () => {
const world = new TimelineWorld();
const initial = world.sync.registerVisibleAgentIds("host:workspace", ["agent-a"]);
const replacement = world.sync.registerVisibleAgentIds("host:workspace", ["agent-b"]);
initial.dispose();
replacement.replace(["agent-c"]);
world.sync.setConnected(true);
const membership = await world.nextMembership();
expect(membership.agentIds).toEqual(["agent-c"]);
membership.succeed();
replacement.dispose();
replacement.replace(["agent-d"]);
world.expectNoPendingMembership();
expect(world.cacheRequests).not.toContain("agent-d");
});
test("a failed catch-up reports once and retries through the explicit retry policy", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const failed = await world.nextFetch("agent-a");
@@ -620,7 +497,7 @@ test("a failed catch-up reports once and retries through the explicit retry poli
test("a failed catch-up retries with exponential backoff", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
@@ -649,7 +526,7 @@ test("a failed catch-up retries with exponential backoff", async () => {
test("manual retries can immediately re-attempt a failed catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
@@ -669,7 +546,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () =>
test("redeclaring unchanged visibility does not bypass catch-up backoff", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
@@ -677,7 +554,7 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async
failed.fail("timeline unavailable");
await world.nextRetry();
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
@@ -687,7 +564,7 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async
test("a manual retry that fails returns to the error state", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
@@ -706,15 +583,14 @@ test("a manual retry that fails returns to the error state", async () => {
test("gap recovery supersedes completed catch-up and pages through the current tail", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const initial = await world.nextFetch("agent-a");
initial.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 12);
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
const gapPage = await world.nextFetch("agent-a");
gapPage.respond({ hasNewer: true, seq: 15 });
const finalPage = await world.nextFetch("agent-a");
@@ -739,7 +615,7 @@ test("gap recovery supersedes completed catch-up and pages through the current t
test("repeated recovery for the same running gap reuses the in-flight fetch", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const initial = await world.nextFetch("agent-a");
@@ -758,7 +634,7 @@ test("repeated recovery for the same running gap reuses the in-flight fetch", as
test("membership failure autonomously retries without another visibility declaration", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]);
@@ -782,7 +658,7 @@ test("membership failure autonomously retries without another visibility declara
test("membership failures retry with exponential backoff", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const first = await world.nextMembership();
first.fail("subscription unavailable");
@@ -810,13 +686,13 @@ test("membership failures retry with exponential backoff", async () => {
test("redeclaring unchanged visibility does not bypass membership backoff", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
await world.nextRetry();
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
@@ -826,13 +702,13 @@ test("redeclaring unchanged visibility does not bypass membership backoff", asyn
test("backgrounding preserves the hot membership without catch-up on return", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const agentAMembership = await world.nextMembership();
agentAMembership.succeed();
const agentACatchUp = await world.nextFetch("agent-a");
agentACatchUp.respond({ hasNewer: false });
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
const agentBMembership = await world.nextMembership();
agentBMembership.succeed();
const agentBCatchUp = await world.nextFetch("agent-b");
@@ -851,12 +727,12 @@ test("backgrounding preserves the hot membership without catch-up on return", as
test("stale membership retry cannot overwrite a newer effective set", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const staleRetry = await world.nextRetry();
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
const current = await world.nextMembership();
staleRetry();
current.succeed();
@@ -874,7 +750,7 @@ test("stale membership retry cannot overwrite a newer effective set", async () =
test("membership retry cannot run while disconnected", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const disconnectedRetry = await world.nextRetry();
@@ -894,13 +770,13 @@ test("membership retry cannot run while disconnected", async () => {
test("returning to a hot hidden agent stays live after inactivity", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const initialMembership = await world.nextMembership();
initialMembership.succeed();
const agentACatchUp = await world.nextFetch("agent-a");
agentACatchUp.respond({ hasNewer: false });
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
const expandedMembership = await world.nextMembership();
expandedMembership.succeed();
const agentBCatchUp = await world.nextFetch("agent-b");
@@ -908,7 +784,7 @@ test("returning to a hot hidden agent stays live after inactivity", async () =>
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
world.elapse(60_000);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
@@ -918,7 +794,7 @@ test("the hot set evicts the least-recent hidden agent and promotes revisited ag
const world = new TimelineWorld();
world.sync.setConnected(true);
for (const agentId of ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]) {
world.sync.registerVisibleAgentIds("workspace", [agentId]);
world.sync.replaceVisibleAgentIds("workspace", [agentId]);
const membership = await world.nextMembership();
membership.succeed();
const catchUp = await world.nextFetch(agentId);
@@ -926,22 +802,22 @@ test("the hot set evicts the least-recent hidden agent and promotes revisited ag
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus(agentId)).toBe("ready"));
}
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
world.sync.registerVisibleAgentIds("workspace", ["agent-f"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]);
const eviction = await world.nextMembership();
eviction.succeed();
const agentFCatchUp = await world.nextFetch("agent-f");
agentFCatchUp.respond({ hasNewer: false });
expect(eviction.agentIds).toEqual(["agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]);
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const restoreEvicted = await world.nextMembership();
restoreEvicted.succeed();
const agentACatchUp = await world.nextFetch("agent-a");
@@ -953,7 +829,7 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn
const world = new TimelineWorld();
const visible = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f"];
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", visible);
world.sync.replaceVisibleAgentIds("workspace", visible);
const membership = await world.nextMembership();
membership.succeed();
const catchUps = await Promise.all(visible.map((agentId) => world.nextFetch(agentId)));
@@ -961,7 +837,7 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn
expect(membership.agentIds).toEqual(visible);
world.sync.registerVisibleAgentIds("workspace", visible.slice(0, 5));
world.sync.replaceVisibleAgentIds("workspace", visible.slice(0, 5));
const hiddenEviction = await world.nextMembership();
hiddenEviction.succeed();
expect(hiddenEviction.agentIds).toEqual(visible.slice(0, 5));
@@ -970,13 +846,13 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn
test("disconnect clears hidden hot agents before reconnecting the visible set", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const agentAMembership = await world.nextMembership();
agentAMembership.succeed();
const agentACatchUp = await world.nextFetch("agent-a");
agentACatchUp.respond({ hasNewer: false });
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
const agentBMembership = await world.nextMembership();
agentBMembership.succeed();
const agentBCatchUp = await world.nextFetch("agent-b");
@@ -993,7 +869,7 @@ test("disconnect clears hidden hot agents before reconnecting the visible set",
expect(restored.agentIds).toEqual(["agent-b"]);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const reopened = await world.nextMembership();
reopened.succeed();
const reopenedCatchUp = await world.nextFetch("agent-a");
@@ -1004,7 +880,7 @@ test("disconnect clears hidden hot agents before reconnecting the visible set",
test("legacy delivery skips subscription RPCs while retaining visibility catch-up and gap recovery", async () => {
const world = new TimelineWorld();
world.sync.setDeliveryMode("legacy");
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.setConnected(true);
world.expectNoPendingMembership();
@@ -1012,8 +888,7 @@ test("legacy delivery skips subscription RPCs while retaining visibility catch-u
initial.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 12);
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
const recovery = await world.nextFetch("agent-a");
recovery.respond({ hasNewer: false });
@@ -1029,17 +904,17 @@ test("legacy delivery catches up after returning to a view or foreground", async
const world = new TimelineWorld();
world.sync.setDeliveryMode("legacy");
world.sync.setConnected(true);
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const firstAgentA = await world.nextFetch("agent-a");
firstAgentA.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
const agentB = await world.nextFetch("agent-b");
agentB.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const secondAgentA = await world.nextFetch("agent-a");
secondAgentA.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
@@ -1055,7 +930,7 @@ test("legacy delivery catches up after returning to a view or foreground", async
test("switching from legacy to selective delivery publishes membership and catches up once", async () => {
const world = new TimelineWorld();
world.sync.setDeliveryMode("legacy");
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.setConnected(true);
const legacyCatchUp = await world.nextFetch("agent-a");
legacyCatchUp.respond({ hasNewer: false });
+426 -215
View File
@@ -1,23 +1,312 @@
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import {
planTimelineCatchUpAfter,
planTimelineResumeFetch,
type ProjectedTimelineForwardFetchPlan,
} from "./timeline-sync-plan";
import type { CachedTimeline } from "@/runtime/replica-cache";
import {
createAgentStreamReducerQueue,
scheduleAgentStreamReducerFlush,
cancelAgentStreamReducerFlush,
selectAgentTimelineState,
useSessionStore,
type AgentTimelineCursorState,
type AgentTimelineState,
} from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getSendingClientMessageIds } from "@/composer/submission/model";
import {
getInitDeferred,
getInitKey,
rejectInitDeferred,
resolveInitDeferred,
} from "@/utils/agent-initialization";
import {
createSessionAgentStreamReducerQueue,
type AgentStreamReducerEvent,
type ProcessTimelineResponseOutput,
processTimelineResponse,
} from "./session-stream-reducers";
import type { TimelineReplica, TimelineResponsePayload, TimelinePageRequest } from "./replica";
import type { AgentRemovalReason } from "@/utils/agent-directory-sync";
import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan";
import { replaceWithCanonicalStream } from "@/types/stream";
export interface TimelineReplicaStorage {
readTimeline(serverId: string, agentId: string): Promise<CachedTimeline | undefined>;
commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void;
}
async function prepareCachedTimeline(input: {
serverId: string;
agentId: string;
storage: TimelineReplicaStorage;
prepareAgent: (agentId: string) => Promise<void>;
}): Promise<CachedTimeline | undefined> {
const before = useSessionStore.getState().sessions[input.serverId];
const beforeTimeline = selectAgentTimelineState(before, input.agentId);
if (beforeTimeline.status === "synced") return undefined;
const [stored] = await Promise.all([
input.storage.readTimeline(input.serverId, input.agentId),
input.prepareAgent(input.agentId),
]);
if (!stored) return undefined;
const session = useSessionStore.getState().sessions[input.serverId];
const currentTimeline = selectAgentTimelineState(session, input.agentId);
const currentHead = session?.agentStreamHead.get(input.agentId);
if (currentTimeline.status === "synced") return undefined;
if (!stored.range) {
if (beforeTimeline.status === "painted") {
return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items
? stored
: undefined;
}
if (currentTimeline.status !== "cold") return undefined;
}
const liveItems =
currentTimeline.status === "painted"
? [...currentTimeline.items, ...(currentHead ?? [])]
: (currentHead ?? []);
const replacement = replaceWithCanonicalStream({
canonical: stored.items,
previousTail: [],
previousHead: liveItems,
sendingClientMessageIds: getSendingClientMessageIds(
session?.messageSubmissions.get(input.agentId),
),
preserveContinuity: true,
// Display-only rows claim no coverage over the live head.
canonicalCoverage: stored.range ?? {
epoch: liveItems.find((item) => item.timelineCursor)?.timelineCursor?.epoch ?? "",
endSeq: null,
},
});
useSessionStore.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, {
items: replacement.tail,
head: replacement.head,
range: stored.range,
older: stored.hasOlder ? "available" : "none",
newer: false,
synchronized: false,
acknowledgedClientMessageIds: replacement.acknowledgedClientMessageIds,
});
return stored;
}
export interface TimelineReplica {
prepare(agentId: string): Promise<void>;
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
readRange(agentId: string): AgentTimelineCursorState | undefined;
timelineUpdated(agentId: string): void;
}
class TimelineReplicaOwner implements TimelineReplica {
private readonly cachedRanges = new Map<string, AgentTimelineCursorState>();
private readonly preparations = new Map<string, Promise<void>>();
constructor(
private readonly serverId: string,
private readonly storage: TimelineReplicaStorage,
private readonly prepareAgent: (agentId: string) => Promise<void>,
) {}
async prepare(agentId: string): Promise<void> {
const existing = this.preparations.get(agentId);
if (existing) return existing;
const preparation = this.load(agentId).finally(() => {
if (this.preparations.get(agentId) === preparation) {
this.preparations.delete(agentId);
}
});
this.preparations.set(agentId, preparation);
return preparation;
}
private async load(agentId: string): Promise<void> {
const stored = await prepareCachedTimeline({
serverId: this.serverId,
agentId,
storage: this.storage,
prepareAgent: this.prepareAgent,
});
if (!stored) return;
if (stored.range) {
this.cachedRanges.set(agentId, stored.range);
}
}
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined {
const range = this.cachedRanges.get(agentId);
return range ? { epoch: range.epoch, endSeq: range.endSeq } : undefined;
}
readRange(agentId: string): AgentTimelineCursorState | undefined {
return this.cachedRanges.get(agentId);
}
timelineUpdated(agentId: string): void {
const session = useSessionStore.getState().sessions[this.serverId];
const timeline = selectAgentTimelineState(session, agentId);
if (timeline.status === "cold") return;
if (timeline.status === "synced") this.cachedRanges.delete(agentId);
this.storage.commitTimeline(this.serverId, agentId, {
agentId,
items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])],
range: timeline.status === "synced" ? timeline.range : null,
hasOlder: timeline.status === "synced" && timeline.older === "available",
});
}
}
export function createTimelineReplica(input: {
serverId: string;
storage: TimelineReplicaStorage;
prepareAgent: (agentId: string) => Promise<void>;
}): TimelineReplica {
return new TimelineReplicaOwner(input.serverId, input.storage, input.prepareAgent);
}
export interface TimelinePageResult {
hasNewer: boolean;
endCursor: { epoch: string; seq: number } | null;
}
export type TimelineResponsePayload = Extract<
SessionOutboundMessage,
{ type: "fetch_agent_timeline_response" }
>["payload"];
export function consumeForcedTimelineTailReplacement(
payload: TimelineResponsePayload,
replacements: Set<string>,
): TimelineResponsePayload {
if (payload.direction !== "tail") return payload;
if (!replacements.delete(payload.agentId)) return payload;
return { ...payload, reset: true };
}
function clearAgentInitializingFlag(serverId: string, agentId: string): void {
useSessionStore.getState().setInitializingAgents(serverId, (previous) => {
if (previous.get(agentId) !== true) return previous;
const next = new Map(previous);
next.set(agentId, false);
return next;
});
}
function commitProcessedTimeline(input: {
serverId: string;
payload: TimelineResponsePayload;
result: ProcessTimelineResponseOutput;
timeline: AgentTimelineState;
currentCursor: AgentTimelineCursorState | undefined;
synchronized: boolean;
}): void {
const { serverId, payload, result, timeline, currentCursor, synchronized } = input;
const agentId = payload.agentId;
const store = useSessionStore.getState();
if (result.commit !== "discard") {
store.applyAgentTimelineResponseState(serverId, agentId, {
items: result.tail,
head: result.head,
range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null),
older: result.older,
newer:
payload.direction === "before"
? timeline.status === "synced" && timeline.newer === "available"
: payload.hasNewer,
synchronized,
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
});
return;
}
if (result.acknowledgedClientMessageIds.length > 0) {
store.setAgentStreamState(serverId, agentId, {
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
});
}
if (payload.direction !== "before") {
store.setAgentTimelineHasNewer(serverId, (current) => {
const next = new Map(current);
next.set(agentId, payload.hasNewer);
return next;
});
}
store.markAgentHistorySynchronized(serverId, agentId);
}
function finalizeProcessedTimeline(input: {
serverId: string;
agentId: string;
initKey: string;
result: ProcessTimelineResponseOutput;
synchronized: boolean;
recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
drainQueuedAgentMessage: (agentId: string) => void;
}): void {
for (const effect of input.result.sideEffects) {
if (effect.type === "catch_up") input.recoverGap(input.agentId, effect.cursor);
}
if (input.result.clearInitializing) {
clearAgentInitializingFlag(input.serverId, input.agentId);
}
if (input.synchronized) {
useCreateFlowStore
.getState()
.clearByAgent({ serverId: input.serverId, agentId: input.agentId });
const session = useSessionStore.getState().sessions[input.serverId];
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId);
}
if (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey);
}
function applyAuthoritativeTimelineResponse(input: {
serverId: string;
payload: TimelineResponsePayload;
cachedCursor?: AgentTimelineCursorState;
recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
drainQueuedAgentMessage: (agentId: string) => void;
}): boolean {
const { serverId, payload } = input;
const agentId = payload.agentId;
const initKey = getInitKey(serverId, agentId);
const session = useSessionStore.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
const activeInitDeferred = getInitDeferred(initKey);
const currentCursor =
timeline.status === "synced" ? (timeline.range ?? undefined) : input.cachedCursor;
const result = processTimelineResponse({
payload,
currentTail: timeline.status === "cold" ? [] : timeline.items,
currentHead: session?.agentStreamHead.get(agentId) ?? [],
currentCursor,
isInitializing: session?.initializingAgents.get(agentId) === true,
hasActiveInitDeferred: Boolean(activeInitDeferred),
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
sendingClientMessageIds: getSendingClientMessageIds(session?.messageSubmissions.get(agentId)),
});
if (result.error) {
if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId);
if (result.initResolution === "reject") rejectInitDeferred(initKey, new Error(result.error));
return false;
}
const synchronized = isTimelineResumeSnapshotAuthoritative({
direction: payload.direction,
hasNewer: payload.hasNewer,
error: payload.error,
});
commitProcessedTimeline({ serverId, payload, result, timeline, currentCursor, synchronized });
finalizeProcessedTimeline({
serverId,
agentId,
initKey,
result,
synchronized,
recoverGap: input.recoverGap,
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
});
return true;
}
export interface ViewedTimelineSyncPorts {
initialDeliveryMode: TimelineDeliveryMode;
prepare(agentId: string): Promise<void>;
@@ -36,13 +325,8 @@ export interface ViewedTimelineSyncPorts {
export type TimelineDeliveryMode = "legacy" | "selective";
export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying";
export interface ViewedTimelineDemandDeclaration {
replace(agentIds: string[]): void;
dispose(): void;
}
export interface ViewedTimelineUiBridge {
registerVisibleAgentIds(sourceId: string, agentIds: string[]): ViewedTimelineDemandDeclaration;
replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void;
subscribe(listener: () => void): () => void;
getAgentTimelineStatus(agentId: string): ViewedTimelineStatus;
getAgentTimelineError(agentId: string): string | null;
@@ -53,32 +337,19 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
setActive(active: boolean): void;
setConnected(connected: boolean): void;
setDeliveryMode(mode: TimelineDeliveryMode): void;
recoverGap(
agentId: string,
cursor: { epoch: string; endSeq: number },
observedSeq?: number,
): void;
beginAgentLifetime(agentId: string): void;
endAgentLifetime(agentId: string): void;
recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void;
dispose(): void;
}
export type ViewedTimelineOwnerPorts = Omit<
ViewedTimelineSyncPorts,
"prepare" | "replaceDemandedAgentIds" | "readCursor"
"prepare" | "replaceDemandedAgentIds"
>;
export interface ViewedTimelineOwner extends ViewedTimelineSync {
beginTimelineRequest(
agentId: string,
replaceTail?: boolean,
): (payload: TimelineResponsePayload) => void;
applyTimelineResponse(payload: TimelineResponsePayload): void;
enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void;
flushStreamAgent(agentId: string): void;
attachTransport(ports: ViewedTimelineOwnerPorts): () => void;
acceptAgent(agentId: string): void;
removeAgent(agentId: string, reason: AgentRemovalReason): void;
}
export function createViewedTimelineOwner(input: {
@@ -86,78 +357,33 @@ export function createViewedTimelineOwner(input: {
replica: TimelineReplica;
replaceDemandedAgentIds: (agentIds: string[]) => void;
drainQueuedAgentMessage: (agentId: string) => void;
ports?: ViewedTimelineOwnerPorts;
ports: ViewedTimelineOwnerPorts;
}): ViewedTimelineOwner {
let transport = input.ports;
const requireTransport = () => {
if (!transport) throw new Error("Timeline transport is disconnected");
return transport;
};
const sync = createViewedTimelineSync({
initialDeliveryMode: transport?.initialDeliveryMode ?? "legacy",
setSubscription: (ids) => requireTransport().setSubscription(ids),
fetchPage: (id, request) => requireTransport().fetchPage(id, request),
fetchLatestTail: (id) => requireTransport().fetchLatestTail(id),
reportError: (error) => transport?.reportError(error),
schedule: (task, delay) => {
if (transport) return transport.schedule(task, delay);
const timeout = setTimeout(task, delay);
return () => clearTimeout(timeout);
},
...input.ports,
prepare: (agentId) => input.replica.prepare(agentId),
readCursor: (agentId) => input.replica.readCursor(agentId),
readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId),
replaceDemandedAgentIds: input.replaceDemandedAgentIds,
});
const streamQueue = createAgentStreamReducerQueue({
apply: (agentId, events) => input.replica.applyEvents(agentId, events, sync.recoverGap),
scheduleFlush: scheduleAgentStreamReducerFlush,
cancelFlush: cancelAgentStreamReducerFlush,
const streamQueue = createSessionAgentStreamReducerQueue({
serverId: input.serverId,
setAgentStreamState: (...args) => useSessionStore.getState().setAgentStreamState(...args),
setAgentTimelineCursor: (...args) => useSessionStore.getState().setAgentTimelineCursor(...args),
recoverTimelineGap: (agentId, cursor) => sync.recoverGap(agentId, cursor),
onCommitted: (agentId) => input.replica.timelineUpdated(agentId),
});
function applyTimelineResponse(
payload: TimelineResponsePayload,
request?: TimelinePageRequest,
): void {
streamQueue.flushAgent(payload.agentId);
input.replica.applyPage(
payload,
{
recoverGap: (agentId, cursor, observedSeq) => sync.recoverGap(agentId, cursor, observedSeq),
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
},
request,
);
}
return {
...sync,
attachTransport(ports) {
sync.setConnected(false);
transport = ports;
sync.setDeliveryMode(ports.initialDeliveryMode);
return () => {
if (transport !== ports) return;
sync.setConnected(false);
transport = undefined;
};
applyTimelineResponse(payload) {
const accepted = applyAuthoritativeTimelineResponse({
serverId: input.serverId,
payload,
cachedCursor: input.replica.readRange(payload.agentId),
recoverGap: (agentId, cursor) => sync.recoverGap(agentId, cursor),
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
});
if (accepted) input.replica.timelineUpdated(payload.agentId);
},
acceptAgent(agentId) {
input.replica.acceptAgent(agentId);
sync.beginAgentLifetime(agentId);
},
removeAgent(agentId, reason) {
streamQueue.flushAgent(agentId);
sync.endAgentLifetime(agentId);
input.replica.remove(agentId, reason);
},
beginTimelineRequest(agentId, replaceTail = false) {
const request = {
cursor: input.replica.readCursor(agentId) ?? null,
replaceTail,
initialization: getInitDeferred(getInitKey(input.serverId, agentId))?.promise,
};
return (payload) => applyTimelineResponse(payload, request);
},
applyTimelineResponse,
enqueueStreamEvent(agentId, event) {
streamQueue.enqueue(agentId, event);
},
@@ -185,11 +411,6 @@ interface CatchUpState {
retryDelayMs?: number;
}
interface VisibleAgentSource {
declaration: object;
agentIds: string[];
}
const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => {
if (previousDelayMs == null) {
return RETRY_DELAY_MS;
@@ -197,6 +418,40 @@ const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => {
return Math.min(previousDelayMs * 2, MAX_RETRY_DELAY_MS);
};
function isSameCatchUpRequest(
left: ProjectedTimelineForwardFetchPlan | undefined,
right: ProjectedTimelineForwardFetchPlan | undefined,
): boolean {
if (!left || !right || left.direction !== right.direction) return false;
if (left.direction !== "after" || right.direction !== "after") return true;
return left.cursor.epoch === right.cursor.epoch && left.cursor.seq === right.cursor.seq;
}
type CatchUpDecision = "keep" | "keep-and-park" | "replace";
function decideCatchUp(input: {
current: CatchUpState | undefined;
request: ProjectedTimelineForwardFetchPlan;
supersede: boolean;
}): CatchUpDecision {
if (!input.current) return "replace";
if (input.supersede) {
if (
input.current.status === "running" &&
isSameCatchUpRequest(input.current.request, input.request)
) {
return "keep";
}
if (input.current.status === "running" && input.current.request?.direction === "tail") {
return "keep-and-park";
}
return "replace";
}
return input.current.status === "running" || input.current.status === "complete"
? "keep"
: "replace";
}
function normalizeAgentIds(agentIds: string[]): string[] {
return [...new Set(agentIds)].filter(Boolean).sort();
}
@@ -206,13 +461,12 @@ function sameAgentIds(left: string[], right: string[]): boolean {
}
export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): ViewedTimelineSync {
const sources = new Map<string, VisibleAgentSource>();
const sources = new Map<string, string[]>();
const catchUps = new Map<string, CatchUpState>();
const catchUpGenerations = new Map<string, number>();
// The newest live sequence each agent has been told about but whose coverage is not yet
// established. A catch-up is complete only when authoritative coverage reaches it; a page
// built before that sequence existed cannot settle it, however it was requested.
const observedHeads = new Map<string, { epoch: string; seq: number }>();
// Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked.
// Acknowledgement and tail completion are the only drain points.
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
const visibilityCatchUpPending = new Set<string>();
const visibilityCatchUpErrors = new Map<string, string>();
// User-initiated retries only. Background retries stay silent; a retry the user asked for
@@ -235,8 +489,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
let cancelMembershipRetry: (() => void) | null = null;
let recentlyViewedAgentIds: string[] = [];
const visibleAgentIds = () =>
normalizeAgentIds([...sources.values()].flatMap((source) => source.agentIds));
const visibleAgentIds = () => normalizeAgentIds([...sources.values()].flat());
const selectHotAgentIds = (visible: string[]) => {
const visibleSet = new Set(visible);
@@ -290,36 +543,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (changed) notifyListeners();
};
// Cancelling forgets the observed head: the next catch-up starts from the current cursor and
// the daemon returns everything after it.
const cancelCatchUp = (agentId: string) => {
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
catchUps.get(agentId)?.cancelRetry?.();
catchUps.delete(agentId);
observedHeads.delete(agentId);
};
const owedAfter = (
agentId: string,
cursorBefore: { epoch: string; endSeq: number } | undefined,
observedBefore: { epoch: string; seq: number } | undefined,
): ProjectedTimelineForwardFetchPlan | undefined => {
const observed = observedHeads.get(agentId);
const cursor = ports.readCursor(agentId);
if (!observed || !cursor) return undefined;
const advanced =
cursorBefore === undefined ||
cursorBefore.epoch !== cursor.epoch ||
cursor.endSeq > cursorBefore.endSeq;
if (cursor.epoch === observed.epoch && cursor.endSeq < observed.seq) {
if (!advanced && observed === observedBefore)
throw new Error(
`Timeline coverage for ${agentId} has not reached observed sequence ${observed.seq}`,
);
return planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq });
}
observedHeads.delete(agentId);
return undefined;
pendingCatchUps.delete(agentId);
};
const fetchUntilCurrent = async (
@@ -331,41 +559,32 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (!ownsCatchUp(agentId, generation)) return;
try {
let cursorBefore = ports.readCursor(agentId);
let observedBefore = observedHeads.get(agentId);
const page = await ports.fetchPage(agentId, request);
if (!ownsCatchUp(agentId, generation)) return;
if (page.hasNewer && page.endCursor) {
if (
request.direction === "after" &&
page.endCursor.epoch === request.cursor.epoch &&
page.endCursor.seq <= request.cursor.seq
) {
throw new Error(`Timeline page for ${agentId} did not advance its cursor`);
}
if (fallbackToLatestTailOnOverflow) {
cursorBefore = ports.readCursor(agentId);
observedBefore = observedHeads.get(agentId);
await ports.fetchLatestTail(agentId);
if (!ownsCatchUp(agentId, generation)) return;
} else {
await fetchUntilCurrent(
agentId,
generation,
planTimelineCatchUpAfter(page.endCursor),
false,
);
catchUps.set(agentId, { generation, status: "complete" });
setVisibilityCatchUpReady(agentId);
return;
}
} else if (page.hasNewer) {
throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`);
}
const owed = owedAfter(agentId, cursorBefore, observedBefore);
if (owed) {
await fetchUntilCurrent(agentId, generation, owed, false);
await fetchUntilCurrent(
agentId,
generation,
planTimelineCatchUpAfter(page.endCursor),
false,
);
return;
}
if (page.hasNewer) {
throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`);
}
catchUps.set(agentId, { generation, status: "complete" });
const pendingCatchUp = pendingCatchUps.get(agentId);
if (pendingCatchUp) {
startCatchUp(agentId, { request: pendingCatchUp, supersede: true });
return;
}
setVisibilityCatchUpReady(agentId);
} catch (error) {
if (catchUps.get(agentId)?.generation === generation) {
@@ -394,43 +613,69 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
.prepare(agentId)
.catch((error) => ports.reportError(error))
.finally(() => {
if (cacheLoads.get(agentId) !== load) return;
cacheLoads.delete(agentId);
loadedCache.add(agentId);
startCatchUp(agentId);
const pending = pendingCatchUps.get(agentId);
startCatchUp(agentId, { request: pending, supersede: Boolean(pending) });
});
cacheLoads.set(agentId, load);
};
// One catch-up per agent: it runs from the current cursor until the daemon reports nothing
// newer and coverage reaches every observed sequence. A running catch-up is never replaced
// by another request; it finishes its own obligation. `supersede` restarts a failed or
// completed one immediately, which the manual retry and a new gap both need.
const startCatchUp = (agentId: string, options: { supersede?: boolean } = {}) => {
const { supersede = false } = options;
if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) return;
const startCatchUp = (
agentId: string,
options: {
request?: ProjectedTimelineForwardFetchPlan;
supersede?: boolean;
} = {},
) => {
const { request, supersede = false } = options;
if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) {
if (request) pendingCatchUps.set(agentId, request);
return;
}
if (!loadedCache.has(agentId)) {
if (request) pendingCatchUps.set(agentId, request);
ensureCacheLoaded(agentId);
return;
}
const nextRequest = request ?? planTimelineResumeFetch(ports.readCursor(agentId));
const current = catchUps.get(agentId);
if (current?.status === "running") return;
if (current?.status === "complete" && !supersede) return;
const decision = decideCatchUp({ current, request: nextRequest, supersede });
if (decision === "keep-and-park") {
pendingCatchUps.set(agentId, nextRequest);
return;
}
if (decision === "keep") {
return;
}
current?.cancelRetry?.();
const request = planTimelineResumeFetch(ports.readCursor(agentId));
const generation = (catchUpGenerations.get(agentId) ?? 0) + 1;
catchUpGenerations.set(agentId, generation);
const retryDelayMs =
supersede || current?.status !== "error" ? undefined : current.retryDelayMs;
catchUps.set(agentId, { generation, status: "running", request, retryDelayMs });
// A resume with no observed head replays at most one bounded page before taking the latest
// tail; a catch-up that owes observed live rows pages forward until it reaches them.
const resumingWithoutObservation = request.direction === "after" && !observedHeads.has(agentId);
void fetchUntilCurrent(agentId, generation, request, resumingWithoutObservation);
catchUps.set(agentId, {
generation,
status: "running",
request: nextRequest,
retryDelayMs,
});
pendingCatchUps.delete(agentId);
void fetchUntilCurrent(
agentId,
generation,
nextRequest,
request === undefined && nextRequest.direction === "after",
);
};
const startAcknowledgedCatchUps = () => {
for (const agentId of acknowledged) startCatchUp(agentId);
for (const agentId of acknowledged) {
const pendingCatchUp = pendingCatchUps.get(agentId);
startCatchUp(agentId, {
request: pendingCatchUp,
supersede: Boolean(pendingCatchUp),
});
}
};
const reconcileLatestMembership = async (): Promise<void> => {
@@ -510,7 +755,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
notifyListeners();
if (catchUp?.status === "error") {
catchUp.cancelRetry?.();
startCatchUp(agentId, { supersede: true });
startCatchUp(agentId, { request: catchUp.request, supersede: true });
return;
}
cancelMembershipRetry?.();
@@ -596,26 +841,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
getAgentTimelineError(agentId) {
return visibilityCatchUpErrors.get(agentId) ?? null;
},
registerVisibleAgentIds(sourceId, agentIds) {
const declaration = {};
sources.set(sourceId, {
declaration,
agentIds: normalizeAgentIds(agentIds),
});
replaceVisibleAgentIds(sourceId, agentIds) {
const normalized = normalizeAgentIds(agentIds);
if (normalized.length === 0) sources.delete(sourceId);
else sources.set(sourceId, normalized);
publishVisibleMembership();
return {
replace(nextAgentIds) {
const current = sources.get(sourceId);
if (current?.declaration !== declaration) return;
current.agentIds = normalizeAgentIds(nextAgentIds);
publishVisibleMembership();
},
dispose() {
if (sources.get(sourceId)?.declaration !== declaration) return;
sources.delete(sourceId);
publishVisibleMembership();
},
};
},
setActive(nextActive) {
if (active === nextActive) return;
@@ -667,30 +897,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (deliveryMode === "selective" && connected) void reconcileMembership();
else if (connected) startAcknowledgedCatchUps();
},
recoverGap(agentId, cursor, observedSeq) {
recoverGap(agentId, cursor) {
if (!isDesired(agentId)) return;
if (observedSeq !== undefined) {
const observed = observedHeads.get(agentId);
if (!observed || observed.epoch !== cursor.epoch || observed.seq < observedSeq) {
observedHeads.set(agentId, { epoch: cursor.epoch, seq: observedSeq });
}
}
startCatchUp(agentId, { supersede: true });
},
beginAgentLifetime(agentId) {
if (!isDesired(agentId)) return;
visibilityCatchUpPending.add(agentId);
ensureCacheLoaded(agentId);
notifyListeners();
},
endAgentLifetime(agentId) {
cancelCatchUp(agentId);
loadedCache.delete(agentId);
cacheLoads.delete(agentId);
if (isDesired(agentId)) {
visibilityCatchUpPending.add(agentId);
notifyListeners();
}
startCatchUp(agentId, {
request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }),
supersede: true,
});
},
dispose() {
disposed = true;
@@ -707,7 +919,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
recentlyViewedAgentIds = [];
loadedCache.clear();
cacheLoads.clear();
observedHeads.clear();
visibilityCatchUpPending.clear();
visibilityCatchUpErrors.clear();
manualRetries.clear();
-117
View File
@@ -8,7 +8,6 @@ import {
handoffCreatedAgentUserMessageToStream,
hydrateStreamState,
mergeToolCallDetail,
mergeCanonicalText,
reduceStreamUpdate,
streamTimelineItemIdentity,
type AgentToolCallItem,
@@ -24,122 +23,6 @@ import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled";
describe("canonical text source boundaries", () => {
it("keeps the canonical start independently of its completion chunk", () => {
const rows = mergeCanonicalText(
[
{
kind: "assistant_message",
id: "m",
messageId: "m",
text: "ab",
timestamp: new Date(0),
timelineCursor: { epoch: "e", seq: 2 },
source: {
startSeq: 1,
chunks: [
{ seq: 1, offset: 0 },
{ seq: 2, offset: 1 },
],
},
},
],
{ text: "ab", epoch: "e", startSeq: 1, endSeq: 2, timestamp: new Date(1) },
);
expect(rows[0]).toMatchObject({
text: "ab",
timelineCursor: { epoch: "e", seq: 2 },
source: { startSeq: 1, chunks: [{ seq: 2, offset: 0 }] },
});
});
it("keeps exact offsets when canonical text replaces the middle of a row", () => {
const [row] = mergeCanonicalText(
[
{
kind: "assistant_message",
id: "m",
text: "abOLDyz",
timestamp: new Date(0),
timelineCursor: { epoch: "e", seq: 8 },
source: {
startSeq: 1,
chunks: [
{ seq: 2, offset: 0 },
{ seq: 4, offset: 2 },
{ seq: 8, offset: 5 },
],
},
},
],
{ text: "C", epoch: "e", startSeq: 3, endSeq: 6, timestamp: new Date(1) },
);
expect(row).toMatchObject({
text: "abCyz",
timelineCursor: { epoch: "e", seq: 8 },
source: {
startSeq: 1,
chunks: [
{ seq: 2, offset: 0 },
{ seq: 6, offset: 2 },
{ seq: 8, offset: 3 },
],
},
});
});
it("preserves source starts and exact text before and after covered chunks", () => {
const rows = mergeCanonicalText(
[
{
kind: "assistant_message",
id: "before",
text: "before",
timestamp: new Date(0),
timelineCursor: { epoch: "e", seq: 2 },
source: { startSeq: 1, chunks: [{ seq: 2, offset: 0 }] },
},
{
kind: "assistant_message",
id: "covered",
text: "old",
timestamp: new Date(0),
timelineCursor: { epoch: "e", seq: 4 },
source: { startSeq: 3, chunks: [{ seq: 4, offset: 0 }] },
},
{
kind: "assistant_message",
id: "after",
text: "after",
timestamp: new Date(0),
timelineCursor: { epoch: "e", seq: 8 },
source: { startSeq: 7, chunks: [{ seq: 8, offset: 0 }] },
},
],
{ text: "canonical", epoch: "e", startSeq: 3, endSeq: 6, timestamp: new Date(1) },
);
expect(
rows.map((row) => ({ text: row.text, source: row.source, cursor: row.timelineCursor })),
).toEqual([
{
text: "before",
source: { startSeq: 1, chunks: [{ seq: 2, offset: 0 }] },
cursor: { epoch: "e", seq: 2 },
},
{
text: "canonical",
source: { startSeq: 3, chunks: [{ seq: 6, offset: 0 }] },
cursor: { epoch: "e", seq: 6 },
},
{
text: "after",
source: { startSeq: 7, chunks: [{ seq: 8, offset: 0 }] },
cursor: { epoch: "e", seq: 8 },
},
]);
});
});
describe("plugin timeline rows", () => {
it("uses the protocol identity format for stream tool and plugin rows", () => {
const tool = {
+138 -449
View File
@@ -97,7 +97,6 @@ export interface UserMessageItem {
messageId?: string;
turnId?: string;
timelineCursor?: TimelinePosition;
source?: RowSource;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
@@ -110,7 +109,6 @@ export interface UserMessageInput {
messageId?: string;
turnId?: string;
timelineCursor?: TimelinePosition;
source?: RowSource;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
@@ -129,7 +127,6 @@ export function createUserMessage(input: UserMessageInput): UserMessageItem {
...(input.messageId ? { messageId: input.messageId } : {}),
...(input.turnId ? { turnId: input.turnId } : {}),
...(input.timelineCursor ? { timelineCursor: input.timelineCursor } : {}),
...(input.source ? { source: input.source } : {}),
text: input.text,
timestamp: input.timestamp,
...(input.images && input.images.length > 0 ? { images: input.images } : {}),
@@ -264,14 +261,12 @@ function produceUserMessage(
clientMessageId: incoming.clientMessageId ?? existing.clientMessageId,
messageId: incoming.messageId ?? existing.messageId,
timelineCursor: incoming.timelineCursor ?? existing.timelineCursor,
source: incoming.source ?? existing.source,
});
if (
existing.id === merged.id &&
existing.clientMessageId === merged.clientMessageId &&
existing.messageId === merged.messageId &&
existing.timelineCursor === merged.timelineCursor &&
existing.source === merged.source &&
existing.text === merged.text &&
existing.timestamp === merged.timestamp &&
existing.images === merged.images &&
@@ -417,19 +412,13 @@ function placeCanonicalUserMessageAtTail(
};
}
// What canonical rows certify about source positions. A certified range replaces every positioned
// row inside it and drops rows from other epochs. Rows-only canonical data, such as a display-only
// cache, replaces only rows sharing a canonical row's position or an older-or-equal copy of its
// message identity; it never drops rows it did not cover.
export type CanonicalCoverage = { epoch: string; endSeq: number | null } | "rows-only";
export interface CanonicalStreamReplacementInput {
canonical: StreamItem[];
previousTail: StreamItem[];
previousHead: StreamItem[];
sendingClientMessageIds: readonly string[];
preserveContinuity: boolean;
canonicalCoverage: CanonicalCoverage;
canonicalCoverage: { epoch: string; endSeq: number | null };
}
export interface CanonicalStreamReplacementResult {
@@ -438,33 +427,13 @@ export interface CanonicalStreamReplacementResult {
acknowledgedClientMessageIds: string[];
}
function isSamePosition(left: TimelinePosition | undefined, right: TimelinePosition): boolean {
return left !== undefined && left.epoch === right.epoch && left.seq === right.seq;
}
function isCoveredByCanonical(
item: StreamItem,
canonical: readonly StreamItem[],
coverage: CanonicalCoverage,
function isAfterCanonicalCoverage(
position: TimelinePosition,
coverage: CanonicalStreamReplacementInput["canonicalCoverage"],
): boolean {
const position = item.timelineCursor;
if (!position) return false;
if (coverage !== "rows-only") {
return (
position.epoch !== coverage.epoch ||
(coverage.endSeq !== null && position.seq <= coverage.endSeq)
);
}
return canonical.some(
(row) =>
isSamePosition(row.timelineCursor, position) ||
(row.kind === "assistant_message" &&
item.kind === "assistant_message" &&
row.messageId !== undefined &&
row.messageId === item.messageId &&
row.timelineCursor !== undefined &&
row.timelineCursor.epoch === position.epoch &&
row.timelineCursor.seq >= position.seq),
return (
position.epoch === coverage.epoch &&
(coverage.endSeq === null || position.seq > coverage.endSeq)
);
}
@@ -574,53 +543,29 @@ function reconcileReplacementHeadAgainstTail(
};
}
// Positioned rows beyond canonical coverage are daemon history the page did not carry, so they
// survive every replacement. Continuity decides only what happens to rows without a position.
function retainsAcrossReplacement(
item: StreamItem,
input: {
canonical: readonly StreamItem[];
tail: readonly StreamItem[];
preserveContinuity: boolean;
sendingClientMessageIds: ReadonlySet<string>;
canonicalCoverage: CanonicalCoverage;
},
): boolean {
if (item.timelineCursor) {
if (!isCoveredByCanonical(item, input.canonical, input.canonicalCoverage)) return true;
const canonicalTailAssistant = input.tail.at(-1);
return (
input.preserveContinuity &&
item.kind === "assistant_message" &&
canonicalTailAssistant?.kind === "assistant_message" &&
item.text.startsWith(canonicalTailAssistant.text)
);
}
return (
input.preserveContinuity ||
(item.kind === "user_message" &&
item.clientMessageId !== undefined &&
input.sendingClientMessageIds.has(item.clientMessageId))
);
}
function preserveReplacementHead(
canonical: readonly StreamItem[],
tail: StreamItem[],
currentHead: StreamItem[],
preserveContinuity: boolean,
sendingClientMessageIds: ReadonlySet<string>,
canonicalCoverage: CanonicalCoverage,
canonicalCoverage: CanonicalStreamReplacementInput["canonicalCoverage"],
): CanonicalStreamReplacementResult {
const retainedHead = currentHead.filter((item) =>
retainsAcrossReplacement(item, {
canonical,
tail,
preserveContinuity,
sendingClientMessageIds,
canonicalCoverage,
}),
);
const canonicalTailAssistant = tail.at(-1);
const retainedHead = preserveContinuity
? currentHead.filter(
(item) =>
!item.timelineCursor ||
isAfterCanonicalCoverage(item.timelineCursor, canonicalCoverage) ||
(item.kind === "assistant_message" &&
canonicalTailAssistant?.kind === "assistant_message" &&
item.text.startsWith(canonicalTailAssistant.text)),
)
: currentHead.filter(
(item) =>
item.kind === "user_message" &&
item.clientMessageId !== undefined &&
sendingClientMessageIds.has(item.clientMessageId),
);
const { tail: reconciledTail, head: unreconciledHead } = reconcileReplacementHeadAgainstTail(
tail,
retainedHead,
@@ -639,29 +584,7 @@ function preserveReplacementHead(
) {
return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
const continued = continueCanonicalAssistant(liveAssistant, tailAssistant);
if (!continued) {
return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
return {
tail: reconciledTail.slice(0, -1),
head: [
...unreconciledHead.slice(0, liveAssistantIndex),
continued,
...unreconciledHead.slice(liveAssistantIndex + 1),
],
acknowledgedClientMessageIds: [],
};
}
// The live head row that continues the canonical tail's last assistant, merged so the canonical
// text owns the positions it certifies and the live row keeps what came after. Rows without
// provider ids cannot tell a continuation from the next message, so they keep the conservative
// legacy rule and merge only when the live text visibly extends the canonical one.
function continueCanonicalAssistant(
liveAssistant: AssistantMessageItem,
tailAssistant: AssistantMessageItem,
): AssistantMessageItem | null {
const hasNewerCursor =
liveAssistant.timelineCursor !== undefined &&
tailAssistant.timelineCursor !== undefined &&
@@ -669,29 +592,41 @@ function continueCanonicalAssistant(
liveAssistant.timelineCursor.seq > tailAssistant.timelineCursor.seq;
const hasMatchingProviderMessageId =
liveAssistant.messageId !== undefined && liveAssistant.messageId === tailAssistant.messageId;
const extendsText = liveAssistant.text.startsWith(tailAssistant.text);
const hasIdlessTextContinuation =
liveAssistant.messageId === undefined && tailAssistant.messageId === undefined && extendsText;
if (hasNewerCursor && (hasMatchingProviderMessageId || hasIdlessTextContinuation)) {
if (liveAssistant.timelineCursor && tailAssistant.timelineCursor) {
return (
mergeCanonicalText([liveAssistant], {
text: tailAssistant.text,
epoch: tailAssistant.timelineCursor.epoch,
startSeq: rowStartSeq(tailAssistant) ?? tailAssistant.timelineCursor.seq,
endSeq: tailAssistant.timelineCursor.seq,
timestamp: tailAssistant.timestamp,
})[0] ?? liveAssistant
);
}
liveAssistant.messageId === undefined &&
tailAssistant.messageId === undefined &&
liveAssistant.text.startsWith(tailAssistant.text);
const isNewerContinuation =
hasNewerCursor && (hasMatchingProviderMessageId || hasIdlessTextContinuation);
if (isNewerContinuation) {
const text = liveAssistant.text.startsWith(tailAssistant.text)
? liveAssistant.text
: `${tailAssistant.text}${liveAssistant.text}`;
const head = [
...unreconciledHead.slice(0, liveAssistantIndex),
{ ...liveAssistant, text },
...unreconciledHead.slice(liveAssistantIndex + 1),
];
return {
...liveAssistant,
text: extendsText ? liveAssistant.text : `${tailAssistant.text}${liveAssistant.text}`,
tail: reconciledTail.slice(0, -1),
head,
acknowledgedClientMessageIds: [],
};
}
// A live prefix of the canonical text is the same message still streaming from the client's
// view; the canonical text is complete as of the page.
return extendsText ? { ...liveAssistant, text: tailAssistant.text } : null;
if (!liveAssistant.text.startsWith(tailAssistant.text)) {
return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
const head = [
...unreconciledHead.slice(0, liveAssistantIndex),
{ ...liveAssistant, text: tailAssistant.text },
...unreconciledHead.slice(liveAssistantIndex + 1),
];
return {
tail: reconciledTail.slice(0, -1),
head,
acknowledgedClientMessageIds: [],
};
}
export function replaceWithCanonicalStream(
@@ -738,29 +673,24 @@ export function replaceWithCanonicalStream(
nextTail.push(item);
}
const retainedTailRows: StreamItem[] = [];
for (const item of input.previousTail) {
if (item.kind === "user_message" && item.clientMessageId !== undefined) {
if (!unmatchedTailMessages.includes(item)) continue;
const preserveLocal = input.preserveContinuity
? isUnreconciledLocalUserMessage(item)
: sendingClientMessageIds.has(item.clientMessageId);
if (preserveLocal) {
nextTail.push(item);
continue;
}
}
if (
item.timelineCursor &&
!isCoveredByCanonical(item, input.canonical, input.canonicalCoverage)
const retainedTailMessages: UserMessageItem[] = [];
for (const local of unmatchedTailMessages) {
const preserveLocal = input.preserveContinuity
? isUnreconciledLocalUserMessage(local)
: local.clientMessageId !== undefined && sendingClientMessageIds.has(local.clientMessageId);
if (preserveLocal) {
nextTail.push(local);
} else if (
input.preserveContinuity &&
local.timelineCursor &&
isAfterCanonicalCoverage(local.timelineCursor, input.canonicalCoverage)
) {
retainedTailRows.push(item);
retainedTailMessages.push(local);
}
}
nextHead = [...retainedTailRows, ...nextHead];
nextHead = [...retainedTailMessages, ...nextHead];
const replacement = preserveReplacementHead(
input.canonical,
nextTail,
nextHead,
input.preserveContinuity,
@@ -783,7 +713,6 @@ export interface AssistantMessageItem {
timestamp: Date;
blockGroupId?: string;
blockIndex?: number;
source?: RowSource;
}
export interface TimelinePosition {
@@ -791,25 +720,6 @@ export interface TimelinePosition {
seq: number;
}
// Where a row's content came from. `startSeq` is the first source position the row covers; rows
// display in start order because a tool call completes long after it appeared. `chunks` records,
// for streamed text, the sequence that produced each piece and the offset it begins at, so a
// canonical page owns exactly the chunks inside its source coverage and text merges cut on chunk
// boundaries instead of comparing strings. Rows saved before provenance existed order by `seq`.
export interface RowSource {
startSeq: number;
chunks?: SourceChunk[];
}
export interface SourceChunk {
seq: number;
offset: number;
}
// Reducers receive the position of the source rows an event covers; canonical units start before
// they end. Rows keep only the anchor position; the start goes into `source`.
export type SourcedPosition = TimelinePosition & { startSeq?: number };
export type ThoughtStatus = "loading" | "ready";
export interface ThoughtItem {
@@ -820,159 +730,6 @@ export interface ThoughtItem {
text: string;
timestamp: Date;
status: ThoughtStatus;
source?: RowSource;
}
type TextRow = AssistantMessageItem | ThoughtItem;
function anchorOf(cursor: SourcedPosition): TimelinePosition;
function anchorOf(cursor: SourcedPosition | undefined): TimelinePosition | undefined;
function anchorOf(cursor: SourcedPosition | undefined): TimelinePosition | undefined {
if (!cursor) return undefined;
return cursor.startSeq === undefined ? cursor : { epoch: cursor.epoch, seq: cursor.seq };
}
// The source of a row after an event at `cursor` touches it. A row keeps its start while the
// event stays in its epoch; a new epoch starts it over.
function sourceFor(cursor: SourcedPosition, existing?: StreamItem): RowSource {
if (existing?.timelineCursor?.epoch === cursor.epoch) {
const startSeq = existing.source?.startSeq ?? existing.timelineCursor.seq;
return existing.source?.startSeq === startSeq && existing.source.chunks === undefined
? existing.source
: { startSeq };
}
return { startSeq: cursor.startSeq ?? cursor.seq };
}
function positionedRow(
cursor: SourcedPosition | undefined,
): Pick<UserMessageItem, "timelineCursor" | "source"> {
return cursor ? { timelineCursor: anchorOf(cursor), source: sourceFor(cursor) } : {};
}
export function rowStartSeq(item: StreamItem): number | undefined {
return item.source?.startSeq ?? item.timelineCursor?.seq;
}
function rowChunks(row: TextRow): SourceChunk[] {
if (row.source?.chunks) return row.source.chunks;
return row.timelineCursor ? [{ seq: row.timelineCursor.seq, offset: 0 }] : [];
}
function chunkTexts(row: TextRow): Array<{ seq: number; text: string }> {
const chunks = rowChunks(row);
return chunks.map((chunk, index) => ({
seq: chunk.seq,
text: row.text.slice(chunk.offset, chunks[index + 1]?.offset ?? row.text.length),
}));
}
function chunksOf(parts: ReadonlyArray<{ seq: number; text: string }>): SourceChunk[] {
let offset = 0;
return parts.map((part) => {
const chunk = { seq: part.seq, offset };
offset += part.text.length;
return chunk;
});
}
function sliceChunks(chunks: readonly SourceChunk[], start: number, end: number): SourceChunk[] {
const inside = chunks
.filter((chunk) => chunk.offset >= start && chunk.offset < end)
.map((chunk) => ({ seq: chunk.seq, offset: chunk.offset - start }));
const spanning = chunks.findLast((chunk) => chunk.offset < start);
if (spanning && inside[0]?.offset !== 0) inside.unshift({ seq: spanning.seq, offset: 0 });
return inside;
}
export interface CanonicalTextUnit {
text: string;
epoch: string;
startSeq: number;
endSeq: number;
timestamp: Date;
}
// A canonical text unit owns the source positions from `startSeq` through `endSeq`. Among the rows
// that belong to the same message it replaces exactly the chunks inside that coverage, keeps chunks
// before it and after it, and lands where the covered chunks were, so a page that lags the live
// stream restores the prefix it certifies without touching text the stream delivered later. Rows
// keep their identity and split; rows left with no text are dropped.
function coverageOf(seq: number, unit: CanonicalTextUnit): "before" | "covered" | "after" {
if (seq < unit.startSeq) return "before";
return seq > unit.endSeq ? "after" : "covered";
}
export function mergeCanonicalText<Row extends TextRow>(
owned: readonly Row[],
unit: CanonicalTextUnit,
): Row[] {
const parts = owned.map((row) => {
const positioned = row.timelineCursor?.epoch === unit.epoch;
return chunkTexts(row).map((part, index) => ({
seq: part.seq,
startSeq: index === 0 ? (rowStartSeq(row) ?? part.seq) : part.seq,
text: part.text,
where: positioned ? coverageOf(part.seq, unit) : ("covered" as const),
}));
});
let anchor = parts.findIndex((rowParts) => rowParts.some((part) => part.where !== "before"));
if (anchor < 0) anchor = owned.length - 1;
const result: Row[] = [];
for (const [index, row] of owned.entries()) {
const rowParts = parts[index] ?? [];
if (index < anchor) {
result.push(row);
continue;
}
const before = index === anchor ? rowParts.filter((part) => part.where === "before") : [];
const after = rowParts.filter((part) => part.where === "after");
const kept =
index === anchor
? [
...before,
{
seq: unit.endSeq,
startSeq: unit.startSeq,
text: unit.text,
where: "covered" as const,
},
...after,
]
: after;
if (kept.length === 0) continue;
const text = kept.map((part) => part.text).join("");
const last = kept.at(-1);
const first = kept[0];
result.push({
...row,
text,
timestamp: index === anchor && after.length === 0 ? unit.timestamp : row.timestamp,
timelineCursor: { epoch: unit.epoch, seq: last?.seq ?? unit.endSeq },
source: {
startSeq: first?.startSeq ?? unit.startSeq,
chunks: chunksOf(kept),
},
});
}
return result;
}
// Appends one text row's content to another, keeping every chunk position.
export function joinTextRows<Row extends TextRow>(first: Row, second: Row): Row {
const parts = [...chunkTexts(first), ...chunkTexts(second)];
return {
...second,
text: `${first.text}${second.text}`,
...(second.timelineCursor
? {
source: {
startSeq: rowStartSeq(first) ?? second.timelineCursor.seq,
chunks: chunksOf(parts),
},
}
: {}),
};
}
export type OrchestratorToolCallStatus = "executing" | "completed" | "failed";
@@ -1005,7 +762,6 @@ export interface ToolCallItem {
kind: "tool_call";
id: string;
timelineCursor?: TimelinePosition;
source?: RowSource;
turnId?: string;
timestamp: Date;
payload: ToolCallPayload;
@@ -1026,7 +782,6 @@ export interface NotificationItem {
sourceType: "error" | "notification";
id: string;
timelineCursor?: TimelinePosition;
source?: RowSource;
turnId?: string;
timestamp: Date;
level: NotificationLevel;
@@ -1037,7 +792,6 @@ export interface CompactionItem {
kind: "compaction";
id: string;
timelineCursor?: TimelinePosition;
source?: RowSource;
turnId?: string;
timestamp: Date;
status: "loading" | "completed";
@@ -1049,7 +803,6 @@ export interface PluginTimelineStreamItem {
kind: "plugin";
id: string;
timelineCursor?: TimelinePosition;
source?: RowSource;
turnId?: string;
timestamp: Date;
pluginId: string;
@@ -1075,7 +828,6 @@ export interface TodoListItem {
kind: "todo_list";
id: string;
timelineCursor?: TimelinePosition;
source?: RowSource;
turnId?: string;
timestamp: Date;
provider: AgentProvider;
@@ -1088,7 +840,7 @@ export type StreamUpdateSource = "live" | "canonical";
interface StreamUpdateOptions {
source?: StreamUpdateSource;
reservedItemIds?: ReadonlySet<string>;
timelineCursor?: SourcedPosition;
timelineCursor?: TimelinePosition;
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -1149,7 +901,7 @@ function appendUserMessage(
id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp),
clientMessageId,
messageId,
...positionedRow(timelineCursor),
timelineCursor,
turnId,
text: chunk,
timestamp,
@@ -1164,7 +916,7 @@ function appendAssistantMessage(
source: StreamUpdateSource,
messageId?: string,
reservedItemIds?: ReadonlySet<string>,
timelineCursor?: SourcedPosition,
timelineCursor?: TimelinePosition,
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
if (!chunk) {
@@ -1177,7 +929,13 @@ function appendAssistantMessage(
last.kind === "assistant_message" &&
(messageId === undefined || last.messageId === messageId);
if (shouldAppendToLast) {
return [...state.slice(0, -1), extendTextRow(last, chunk, timestamp, timelineCursor)];
const updated: AssistantMessageItem = {
...last,
text: `${last.text}${chunk}`,
timestamp,
...(timelineCursor ? { timelineCursor } : {}),
};
return [...state.slice(0, -1), updated];
}
// A submitted user row can follow the streaming assistant during interrupt.
@@ -1189,11 +947,13 @@ function appendAssistantMessage(
secondLast?.kind === "assistant_message" &&
(messageId === undefined || secondLast.messageId === messageId)
) {
return [
...state.slice(0, -2),
extendTextRow(secondLast, chunk, timestamp, timelineCursor),
last,
];
const updated: AssistantMessageItem = {
...secondLast,
text: `${secondLast.text}${chunk}`,
timestamp,
...(timelineCursor ? { timelineCursor } : {}),
};
return [...state.slice(0, -2), updated, last];
}
if (!hasContent) {
@@ -1206,53 +966,18 @@ function appendAssistantMessage(
kind: "assistant_message",
id: entryId,
...(messageId ? { messageId } : {}),
...startedTextRow(timelineCursor),
...(timelineCursor ? { timelineCursor } : {}),
text: chunk,
timestamp,
};
return [...state, item];
}
function startedTextRow(
timelineCursor: SourcedPosition | undefined,
): Pick<TextRow, "timelineCursor" | "source"> {
if (!timelineCursor) return {};
return {
timelineCursor: anchorOf(timelineCursor),
source: {
startSeq: timelineCursor.startSeq ?? timelineCursor.seq,
chunks: [{ seq: timelineCursor.seq, offset: 0 }],
},
};
}
function extendTextRow<Row extends TextRow>(
row: Row,
chunk: string,
timestamp: Date,
timelineCursor: SourcedPosition | undefined,
): Row {
return {
...row,
text: `${row.text}${chunk}`,
timestamp,
...(timelineCursor
? {
timelineCursor: anchorOf(timelineCursor),
source: {
startSeq: sourceFor(timelineCursor, row).startSeq,
chunks: [...rowChunks(row), { seq: timelineCursor.seq, offset: row.text.length }],
},
}
: {}),
};
}
function appendThought(
state: StreamItem[],
text: string,
timestamp: Date,
timelineCursor?: SourcedPosition,
timelineCursor?: TimelinePosition,
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
if (!chunk) {
@@ -1261,10 +986,14 @@ function appendThought(
const last = state[state.length - 1];
if (last && last.kind === "thought") {
return [
...state.slice(0, -1),
{ ...extendTextRow(last, chunk, timestamp, timelineCursor), status: "loading" },
];
const updated: ThoughtItem = {
...last,
...(timelineCursor ? { timelineCursor } : {}),
text: `${last.text}${chunk}`,
timestamp,
status: "loading",
};
return [...state.slice(0, -1), updated];
}
if (!hasContent) {
@@ -1275,7 +1004,7 @@ function appendThought(
const item: ThoughtItem = {
kind: "thought",
id: createUniqueTimelineId(state, "thought", idSeed, timestamp),
...startedTextRow(timelineCursor),
...(timelineCursor ? { timelineCursor } : {}),
text: chunk,
timestamp,
status: "loading",
@@ -1427,7 +1156,7 @@ export function mergeAgentToolCallItem(
existing: AgentToolCallItem,
data: AgentToolCallData,
timestamp: Date,
timelineCursor?: SourcedPosition,
timelineCursor?: TimelinePosition,
): AgentToolCallItem {
const mergedStatus = mergeAgentToolCallStatus(existing.payload.data.status, data.status);
const mergedError =
@@ -1439,9 +1168,7 @@ export function mergeAgentToolCallItem(
return {
...existing,
...(timelineCursor
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, existing) }
: {}),
...(timelineCursor ? { timelineCursor } : {}),
timestamp,
payload: {
source: "agent",
@@ -1462,7 +1189,7 @@ interface AppendAgentToolCallInput {
data: AgentToolCallData;
timestamp: Date;
turnId?: string;
timelineCursor?: SourcedPosition;
timelineCursor?: TimelinePosition;
}
function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] {
@@ -1498,9 +1225,7 @@ function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] {
const item: ToolCallItem = {
kind: "tool_call",
id: `agent_tool_${identity}`,
...(timelineCursor
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) }
: {}),
...(timelineCursor ? { timelineCursor } : {}),
...(turnId ? { turnId } : {}),
timestamp,
payload: {
@@ -1529,7 +1254,7 @@ function appendPluginTimelineItem(
state: StreamItem[],
item: Extract<AgentTimelineItem, { type: "plugin" }>,
timestamp: Date,
timelineCursor?: SourcedPosition,
timelineCursor?: TimelinePosition,
): StreamItem[] {
const identity = timelineItemIdentity(item);
if (identity === null) return state;
@@ -1542,22 +1267,14 @@ function appendPluginTimelineItem(
version: item.version,
data: item.data,
timestamp,
...(timelineCursor
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) }
: {}),
...(timelineCursor ? { timelineCursor } : {}),
};
const existingIndex = findExistingTimelineIdentityIndex(state, identity);
if (existingIndex < 0) return [...state, nextItem];
const existing = state[existingIndex];
if (!existing || existing.kind !== "plugin") return state;
const next = [...state];
next[existingIndex] = {
...nextItem,
id: existing.id,
...(timelineCursor
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, existing) }
: {}),
};
next[existingIndex] = { ...nextItem, id: existing.id };
return next;
}
@@ -1566,7 +1283,7 @@ function appendTodoList(
provider: AgentProvider,
items: TodoEntry[],
timestamp: Date,
timelineCursor?: SourcedPosition,
timelineCursor?: TimelinePosition,
): StreamItem[] {
const normalizedItems = items.map((item) => ({
text: item.text,
@@ -1588,9 +1305,7 @@ function appendTodoList(
const next = [...state];
next[previousIndex] = {
...previous,
...(timelineCursor
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, previous) }
: {}),
...(timelineCursor ? { timelineCursor } : {}),
items: normalizedItems,
timestamp,
};
@@ -1609,9 +1324,7 @@ function appendTodoList(
const next = [...state];
next[next.length - 1] = {
...lastItem,
...(timelineCursor
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, lastItem) }
: {}),
...(timelineCursor ? { timelineCursor } : {}),
items: normalizedItems,
activity: { type: "created", count: normalizedItems.length },
timestamp,
@@ -1625,9 +1338,7 @@ function appendTodoList(
next.push({
kind: "todo_list",
id: createUniqueTimelineId(next, "todo", idSeed, timestamp),
...(timelineCursor
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) }
: {}),
...(timelineCursor ? { timelineCursor } : {}),
timestamp,
provider,
items: normalizedItems,
@@ -1682,7 +1393,7 @@ function reduceTimelineToolCall(
{ type: "tool_call" }
>,
timestamp: Date,
timelineCursor?: SourcedPosition,
timelineCursor?: TimelinePosition,
): StreamItem[] {
const normalizedToolName = item.name
.trim()
@@ -1753,7 +1464,7 @@ function reduceTimelineCompaction(
{ type: "compaction" }
>,
timestamp: Date,
timelineCursor?: SourcedPosition,
timelineCursor?: TimelinePosition,
): StreamItem[] {
if (item.status === "completed") {
const loadingIdx = state.findIndex((s) => s.kind === "compaction" && s.status === "loading");
@@ -1761,12 +1472,7 @@ function reduceTimelineCompaction(
if (loadingIdx >= 0 && existing && existing.kind === "compaction") {
const updated: CompactionItem = {
...existing,
...(timelineCursor
? {
timelineCursor: anchorOf(timelineCursor),
source: sourceFor(timelineCursor, existing),
}
: {}),
...(timelineCursor ? { timelineCursor } : {}),
status: "completed",
trigger: item.trigger ?? existing.trigger,
preTokens: item.preTokens ?? existing.preTokens,
@@ -1780,9 +1486,7 @@ function reduceTimelineCompaction(
const compaction: CompactionItem = {
kind: "compaction",
id: createUniqueTimelineId(state, "compaction", item.status, timestamp),
...(timelineCursor
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) }
: {}),
...(timelineCursor ? { timelineCursor } : {}),
timestamp,
status: item.status,
trigger: item.trigger,
@@ -1797,7 +1501,7 @@ function reduceTimelineEvent(
timestamp: Date,
source: StreamUpdateSource,
reservedItemIds?: ReadonlySet<string>,
timelineCursor?: SourcedPosition,
timelineCursor?: TimelinePosition,
): StreamItem[] {
const item = event.item;
switch (item.type) {
@@ -1849,9 +1553,7 @@ function reduceTimelineEvent(
kind: "notification",
sourceType: "error",
id: createUniqueTimelineId(state, "error", item.message ?? "", timestamp),
...(timelineCursor
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) }
: {}),
...(timelineCursor ? { timelineCursor } : {}),
timestamp,
level: "error",
message: item.message ?? "Unknown error",
@@ -1863,9 +1565,7 @@ function reduceTimelineEvent(
kind: "notification",
sourceType: "notification",
id: createUniqueTimelineId(state, "notification", item.message, timestamp),
...(timelineCursor
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) }
: {}),
...(timelineCursor ? { timelineCursor } : {}),
timestamp,
level: item.level,
message: item.message,
@@ -1974,7 +1674,7 @@ export function hydrateStreamState(
events: Array<{
event: AgentStreamEventPayload;
timestamp: Date;
timelineCursor?: SourcedPosition;
timelineCursor?: TimelinePosition;
}>,
options?: Pick<StreamUpdateOptions, "source" | "reservedItemIds">,
): StreamItem[] {
@@ -2128,39 +1828,28 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str
const firstBlockIndex = activeItem.blockIndex ?? 0;
const completedBlocks = blocks.slice(0, -1);
const liveBlock = `${blocks[blocks.length - 1] ?? ""}${getTrailingNewlineSuffix(activeItem.text)}`;
// Blocks are substrings of the row in order; each keeps the chunks that produced it.
const chunks = rowChunks(activeItem);
let searchFrom = 0;
const blockRow = (block: string, blockIndex: number): AssistantMessageItem => {
const start = activeItem.text.indexOf(block, searchFrom);
searchFrom = start + block.length;
const blockChunks = sliceChunks(chunks, start, start + block.length);
return {
...activeItem,
id: createAssistantBlockId({ groupId: blockGroupId, blockIndex }),
blockGroupId,
blockIndex,
text: block,
...(activeItem.timelineCursor && blockChunks.length > 0
? {
timelineCursor: {
epoch: activeItem.timelineCursor.epoch,
seq: blockChunks.at(-1)?.seq ?? activeItem.timelineCursor.seq,
},
source: {
startSeq: blockChunks[0]?.seq ?? activeItem.timelineCursor.seq,
chunks: blockChunks,
},
}
: {}),
};
};
const promotedItems = completedBlocks.map((block, offset) =>
blockRow(block, firstBlockIndex + offset),
);
const promotedItems = completedBlocks.map<AssistantMessageItem>((block, offset) => ({
...activeItem,
id: createAssistantBlockId({
groupId: blockGroupId,
blockIndex: firstBlockIndex + offset,
}),
blockGroupId,
blockIndex: firstBlockIndex + offset,
text: block,
}));
const nextTail = flushHeadToTail(params.tail, promotedItems);
const liveItem = blockRow(liveBlock, firstBlockIndex + completedBlocks.length);
const liveItem: AssistantMessageItem = {
...activeItem,
id: createAssistantBlockId({
groupId: blockGroupId,
blockIndex: firstBlockIndex + completedBlocks.length,
}),
blockGroupId,
blockIndex: firstBlockIndex + completedBlocks.length,
text: liveBlock,
};
const nextHead = [
...params.head.slice(0, assistantIndex),
liveItem,
@@ -2256,7 +1945,7 @@ function applyCanonicalUserMessageEvent(params: {
head: StreamItem[];
event: AgentStreamEventPayload;
timestamp: Date;
timelineCursor?: SourcedPosition;
timelineCursor?: TimelinePosition;
unmatchedInsert?: "tail" | "head";
}): ApplyStreamEventResult | null {
const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params;
@@ -2272,7 +1961,7 @@ function applyCanonicalUserMessageEvent(params: {
messageId: event.item.messageId,
clientMessageId: event.item.clientMessageId,
turnId: event.turnId,
...positionedRow(timelineCursor),
timelineCursor,
text: normalized.chunk,
timestamp,
});
@@ -2346,7 +2035,7 @@ export function applyStreamEvent(params: {
event: AgentStreamEventPayload;
timestamp: Date;
source?: StreamUpdateSource;
timelineCursor?: SourcedPosition;
timelineCursor?: TimelinePosition;
unmatchedUserMessageInsert?: "tail" | "head";
}): ApplyStreamEventResult {
const { tail, head, event, timestamp } = params;
@@ -1,4 +1,3 @@
import { createTimelineReplica } from "@/timeline/replica";
import { describe, expect, it } from "vitest";
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
@@ -66,26 +65,18 @@ function permission(id: string): AgentPermissionRequest {
return { id, provider: "codex", name: id, kind: "tool", title: id };
}
function beginPendingSubmission(serverId: string, agentId: string) {
function beginPendingSubmission(serverId: string, agentId: string): string {
const clientMessageId = `client-${agentId}`;
const replica = createTimelineReplica({
useSessionStore.getState().beginAgentMessageSubmission(
serverId,
storage: {
removeTimeline: () => undefined,
readTimeline: async () => undefined,
commitTimeline: () => undefined,
},
});
replica.beginSubmission(
agentId,
createUserMessage({
clientMessageId,
text: "Run this",
timestamp: new Date("2026-07-27T10:00:00.000Z"),
}),
true,
);
return { clientMessageId, replica };
return clientMessageId;
}
describe("message submission authority", () => {
@@ -94,11 +85,9 @@ describe("message submission authority", () => {
const agentId = "agent-1";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const { clientMessageId } = beginPendingSubmission(serverId, agentId);
const clientMessageId = beginPendingSubmission(serverId, agentId);
const agent = createAgentPayload({ id: agentId, status: "running" });
new AgentStoreProjection(serverId, (removedAgentId) =>
useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId),
).applyDelta({
new AgentStoreProjection(serverId).applyDelta({
kind: "upsert",
agent,
project: createEntry(agent).project,
@@ -119,44 +108,28 @@ describe("message submission authority", () => {
const agentId = "agent-1";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const { clientMessageId, replica } = beginPendingSubmission(serverId, agentId);
replica.handoffSubmission(
agentId,
createUserMessage({
id: "provider-message",
messageId: "provider-message",
clientMessageId,
text: "Run this",
timestamp: new Date("2026-07-27T10:00:01.000Z"),
}),
);
const clientMessageId = beginPendingSubmission(serverId, agentId);
store.setAgentStreamState(serverId, agentId, {
tail: [
createUserMessage({
id: "provider-message",
messageId: "provider-message",
clientMessageId,
text: "Run this",
timestamp: new Date("2026-07-27T10:00:01.000Z"),
}),
],
head: [],
});
expect(
useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0]
?.providerAcknowledged,
).toBe(false);
replica.applyEvents(
agentId,
[
{
seq: 1,
epoch: "epoch",
timestamp: new Date("2026-07-27T10:00:01.000Z"),
event: {
type: "timeline",
provider: "codex",
item: {
type: "user_message",
messageId: "provider-message",
clientMessageId,
text: "Run this",
},
},
},
],
() => undefined,
);
store.setAgentStreamState(serverId, agentId, {
acknowledgedClientMessageIds: [clientMessageId],
});
expect(
useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0]
@@ -173,9 +146,9 @@ describe("replaceFetchedAgentDirectory", () => {
store.initializeSession(serverId, null as unknown as DaemonClient);
store.setInitializingAgents(serverId, new Map([["agent", true]]));
new AgentStoreProjection(serverId, (removedAgentId) =>
useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId),
).replaceFetched([createEntry(createAgentPayload({ id: "agent" }))]);
new AgentStoreProjection(serverId).replaceFetched([
createEntry(createAgentPayload({ id: "agent" })),
]);
expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get("agent")).toBe(
true,
@@ -188,9 +161,7 @@ describe("replaceFetchedAgentDirectory", () => {
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const projection = new AgentStoreProjection(serverId, (removedAgentId) =>
useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId),
);
const projection = new AgentStoreProjection(serverId);
projection.replaceFetched([
createEntry(
createAgentPayload({
@@ -231,15 +202,10 @@ describe("replaceFetchedAgentDirectory", () => {
serverId,
new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]),
);
store.applyAgentTimelineResponseState(serverId, agentId, {
items: [],
head: [],
range: { epoch: "epoch", startSeq: 1, endSeq: 2 },
older: "none",
newer: false,
synchronized: false,
submissions: [],
});
store.setAgentTimelineCursor(
serverId,
new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]),
);
store.setPendingPermissions(
serverId,
new Map([["permission", { key: "permission", agentId, request: null as never }]]),
@@ -247,9 +213,7 @@ describe("replaceFetchedAgentDirectory", () => {
store.setInitializingAgents(serverId, new Map([[agentId, true]]));
setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true });
new AgentStoreProjection(serverId, (removedAgentId) =>
useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId),
).applyDelta({ kind: "remove", agentId });
new AgentStoreProjection(serverId).applyDelta({ kind: "remove", agentId });
const session = useSessionStore.getState().sessions[serverId];
expect({
@@ -309,9 +273,7 @@ describe("replaceFetchedAgentDirectory", () => {
lastUsage: { inputTokens: 10, outputTokens: 5 },
pendingPermissions: [permission("current-permission")],
});
const projection = new AgentStoreProjection(serverId, (removedAgentId) =>
useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId),
);
const projection = new AgentStoreProjection(serverId);
projection.applyDelta({
kind: "upsert",
agent: current,
@@ -10,9 +10,6 @@ export type AgentDirectoryDelta = Extract<
{ type: "agent_update" }
>["payload"];
// Directory membership can disappear without the underlying agent being deleted.
export type AgentRemovalReason = "scope" | "deleted";
interface PendingPermissionEntry {
key: string;
agentId: string;