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
+15 -8
View File
@@ -113,14 +113,21 @@ Cross-platform React Native app that connects to one or more daemons.
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
- Voice features: dictation (STT) and voice agent (realtime)
Consumers request data from host-lived owners without choosing memory, disk or network. Cached rows
satisfy the same readiness projection as network rows, including offline. Directory restoration
merges under live/local changes; network refresh waits for that baseline. Only completed catch-up
advances the directory checkpoint because a live sequence can follow an offline gap. The owner
retains surface demand across reconnects; React does not track connection generations. Storage
commit and scoped-read guarantees belong to [data-model.md](data-model.md#replica-row-store);
timeline display, resume and lifetime contracts belong to
[timeline-sync.md](timeline-sync.md#client-replica-lifetime).
Consumers request directory or timeline data without choosing memory, cache, or network. The owner
publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an
invalid row uses that same path. Offline demand still publishes an accepted cache hit and defers
network reconciliation. Cache loading is demand-driven: opening a chat reads its agent row and
focused timeline row, plus the workspace and project rows needed by the route; opening a directory
reads directory rows for that host and establishes its live subscription when connected. Accepted
cached rows satisfy the same consumer-readiness projection as network rows. Host registry startup and
host connection do not create directory demand or install replicas. The directory owner retains
declared surface demand and re-establishes network reconciliation after reconnect; React does not
track connection generations. Late cache reads cannot replace state already advanced by live or
authoritative network data. Owners explicitly persist accepted commits; directory rows and their
checkpoint share one storage transaction. See
[data-model.md](data-model.md#replica-row-store)
for the storage shape and [timeline-sync.md](timeline-sync.md#client-replica-lifetime) for timeline
resume behavior.
The three directory entity types have independent monotonic sequences and share one daemon
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
+7 -11
View File
@@ -578,20 +578,16 @@ compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`,
uses the singleton id.
The store is a typed persistence boundary. It returns values to directory and timeline owners and
accepts their explicit commits; it never reads or writes UI state.
One invalid row is deleted and returned as a miss without affecting other rows.
accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested
host, kinds, and ids. Opening a cached workspace uses exact workspace and project keys rather than a
directory scan. One invalid row is deleted and returned as a miss without affecting other rows.
An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a
later launch cannot accept a checkpoint for a partial baseline. Directory changes and their
checkpoint are also applied in one transaction. Cold reads query only the requested host's directory
or agent timeline. Accepted commits overlay those reads, including changes accepted or persisted
while a read was in flight. Host removal invalidates old reads; reconciliation exposes moved rows
before the disk rename finishes. Disk is write-behind: a change stays pending until the disk
confirms it, so failed writes preserve accepted values. Timeline eligibility is checked without
serialization; the coalesced write encodes the latest accepted page once.
checkpoint are also applied in one transaction.
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. The first
write loads the global row index and accounts for its bytes; requested reads do not await that
work. Later writes update the index. The row store is not encrypted. A cached timeline can contain
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget
bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or
before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain
source code, prompts, and tool output; encrypted-at-rest storage is a separate security decision.
### Draft Store
+38 -72
View File
@@ -36,24 +36,11 @@ Large unbounded timeline responses can exceed relay frame limits, so catch-up us
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When live delivery detects a sequence gap, the app fetches `direction: "after"` from its current
cursor and remembers the sequence the live row reached. If the daemon responds with
`hasNewer: true`, the app immediately fetches the next page from `endCursor`. A catch-up is
complete only when the daemon reports `hasNewer: false` and the accepted coverage has reached
every sequence the live stream reported while the catch-up ran: a page the daemon built before
those rows existed cannot settle them, however it was requested, so the owner continues from the
cursor that page established. One catch-up runs per agent; a gap observed while it is running
raises its obligation instead of starting another request. A non-advancing reply cannot disprove
activity observed while the request was in flight. A newly raised obligation starts the next
page immediately, without an error. A reply that makes no progress against an obligation already
known when it started retains that obligation and uses bounded error backoff. Cancellation (the agent leaves view,
the connection drops) forgets the observed head; the next catch-up starts from the cursor and the
daemon returns everything after it.
When live delivery detects a sequence gap, the app fetches `direction: "after"`. If the daemon
responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap
recovery is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
Response completion is independent of row admission: an obsolete tail can finish its initialization
without replacing newer accepted rows. Requests carry the existing init-promise identity, so an old
response cannot settle a replacement initialization.
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
user-driven by scrolling upward.
@@ -95,9 +82,7 @@ authoritative range first.
that cursor are applied. Already-covered rows are not replayed.
- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the
latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved
local submissions; it never retains two discontiguous canonical ranges. A tail whose window ends
behind the local cursor is a rewind, so it supersedes every positioned row up to that cursor
rather than only the positions it carries; a gap replacement covers only what it carries.
local submissions; it never retains two discontiguous canonical ranges.
The installed tail carries `hasOlder`, so history skipped by a replacement remains reachable through
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
@@ -105,51 +90,39 @@ history start; a response requested from a pre-replacement range is stale and is
## Client replica lifetime
`timeline/replica.ts` is the host-lived timeline write authority. Cache restore, pages, live batches,
submissions and removal publish atomically into the existing Zustand read projection; unchanged
facts retain their references. `viewed-timeline-sync.ts` owns demand and fetch scheduling, available
before a network client so saved history can paint offline. Matching display and verified inputs
share one reduction. Fully covered identified text adopts canonical row identity and metadata;
partial coverage preserves live rows and newer text. Persistence uses the accepted transition,
never mutable UI state.
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
owner wraps cached preparation, network catch-up, accepted timeline application, and persistence
behind one interface. React supplies transport and projection operations without selecting a cache
path or issuing a separate persistence notification.
Unpositioned live updates do not revoke known in-session source coverage or sequencing: current
daemons also emit out-of-band timeline updates. They can make the restart page display-only, which
grants no resume coverage when reopened. Display pagination remains available, and older pages
cannot certify unpositioned content. Positioned activity ahead of incomplete catch-up preserves the
preceding certified baseline. A cold overflow awaiting its latest tail still accepts live observations;
only an intentionally detached, synchronized history window excludes them.
Epoch changes invalidate old display positions; authoritative resets, including empty windows,
invalidate the old durable baseline. A delayed old-epoch cache cannot replace newer live display.
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
The restart window holds at most the latest40 complete entries in source-start order. Loading older
scrollback cannot populate or enlarge it, even when projection merging leaves a short page or its
certificate has been withdrawn. Retained messages can gain complete continuations; overlapping
older tool lifetimes do not pull earlier entries into the window. `startSeq` identifies the source-start
boundary for `before` pagination; `endSeq` is the independently established synchronization position
for `after`. A discarded older tool may finish last, so the latter can exceed every retained row's
completion position. `hasOlder` preserves access to omitted history.
The timeline owner asks durable replica storage for an agent when that agent becomes visible. An
accepted row paints immediately before subscription acknowledgement or timeline fetch. The stored
range describes those exact items: `startSeq` drives older pagination and `endSeq` drives forward
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
history. Code outside the owner does not distinguish cached and network timelines.
Cache values without source provenance paint verbatim but cannot authorize resume, including after
new live activity. Canonical replacement restores certification through the ordinary bounded tail
bootstrap; no local sorting or text deduplication repairs saved rows. Disjoint retained ranges keep
the preceding certified baseline. The codec preserves complete text, source chunks and attachment
references; unreconciled local submissions and UI sync generations are not durable. Cache budgeting
and write-behind guarantees belong to [data-model.md](data-model.md#replica-row-store).
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
instead of replaying every missed page. Live gap recovery still pages forward until current.
Preparation keeps one promise per agent lifetime. Host identity transfer immediately republishes
retained state; a pending read repeats only when identity changed during that read, because physical
rename may have retired its key. Settled transfers perform no storage read. Directory scope exclusion
ends the lifetime and rejects delayed work but retains the saved timeline; only explicit deletion
removes it. First admission preserves in-flight work; re-admission begins a fresh lifetime. Retained
timeline data alone does not provide offline archived-route metadata.
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
falsely certifies discarded source rows. A display-only row paints without granting synchronization
authority, so the owner uses the ordinary bounded `tail` bootstrap.
DirectorySync coalesces fetch and acceptance into one operation. The first resume is bounded: a reply
with more history leads to one latest tail, while live gap recovery still pages to completion. A tail
passed by activity accepted during its request is obsolete; a tail behind the request's starting
cursor demonstrates rewind. Client window-replacement intent remains separate from daemon reset.
Covered canonical positions replace matching display history, while positioned rows beyond the page
survive as an unverified overlay. Submission continuity follows the rules below.
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
the authoritative range through the existing forward-page path. The cache does not persist sync
generation or unreconciled local submissions.
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
replacement keeps only positioned rows newer than the page it installs, while unresolved local
user presentations identify themselves by having client identity without a timeline position. This
prevents a page from duplicating rows it already covers without coupling display continuity to the
shorter-lived submission registry. Unreconciled local presentations are not persisted in the durable
replica cache.
## Selective and legacy delivery
@@ -171,18 +144,12 @@ version.
## Projected pages reconcile with live presentation
A projected page is canonical state, not live deltas. Positioned rows carry their source start and
text chunk offsets so page reconciliation replaces covered text while retaining newer chunks.
Tools keep their first-appearance order even when their completion arrives later. Both display lanes
share this source order; a missing canonical unit lands at its source start.
Message identity alone does not identify a segment: tools can divide same-ID text, and markdown can
split one segment into several rows. For unpositioned text, reconcile only a uniquely matching
contiguous segment using the same markdown boundaries as live rendering. A lagging canonical prefix
leaves newer text unpositioned. Known tool positions constrain ownership; identical unpositioned
segments without a distinguishing position remain ambiguous and are preserved. Text alone never
establishes message identity. Anonymous rows retain the conservative cursor-straddle fallback.
Insertion must not merge an identified segment whose ownership reconciliation already declined.
A projected page is canonical state, not a sequence of live deltas. One projected item can overlap
rows already received live—for example, a tool call retained at its original display position while
its completion advances `seqEnd`, followed by a merged assistant message. The app uses
`sourceSeqRanges` to replace overlapping assistant and reasoning projections before applying the
remaining page through the existing stream reducer. It must not append full projected text to a
live prefix.
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
send-now, and the host runtime's automatic queue drain — goes through
@@ -263,7 +230,6 @@ canonical assistant prefix, it stays in the head lane. No row may be returned in
- Server live stream forwarding: `packages/server/src/server/session.ts`
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
- App timeline write authority: `packages/app/src/timeline/replica.ts`
- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts`
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
- Session wiring: `packages/app/src/contexts/session-context.tsx`
@@ -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;