From c46aee4e6a2b8b077bd8a0074b468d48eccdcb59 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 19:18:54 +0200 Subject: [PATCH] Revert #4421 and narrow the cache restore fix (#4436) * Revert "Restore cached conversations before reconnecting (#4421)" This reverts commit 1c51b52d079977aea4c9c2b20243d30be112f5f1. * 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. --- docs/architecture.md | 23 +- docs/data-model.md | 18 +- docs/timeline-sync.md | 110 +- .../browser/agent-message-submission.spec.ts | 5 +- .../e2e/browser/composer-autocomplete.spec.ts | 24 +- .../new-workspace-launch-memory.spec.ts | 13 +- .../app/e2e/browser/sidebar-model-b.spec.ts | 5 +- .../support/helpers/agent-timeline-gate.ts | 33 +- .../support/helpers/timeline-pagination.ts | 9 +- .../e2e/support/helpers/timeline-resume.ts | 8 - .../src/app/h/[serverId]/agent/[agentId].tsx | 6 + .../src/composer/draft/create-flow.test.ts | 19 +- .../app/src/composer/draft/create-flow.ts | 25 +- packages/app/src/composer/index.tsx | 5 +- .../src/composer/submission/writer.test.ts | 16 +- .../app/src/composer/submission/writer.ts | 77 +- packages/app/src/contexts/session-context.tsx | 60 +- .../hooks/use-agent-initialization.test.ts | 20 +- .../directory-sync/agent-replica.test.ts | 14 - .../runtime/directory-sync/agent-replica.ts | 70 +- .../src/runtime/directory-sync/index.test.ts | 869 +++---- .../app/src/runtime/directory-sync/index.ts | 252 +- .../directory-sync/internal/agent-store.ts | 20 +- .../directory-sync/workspace-replica.test.ts | 7 +- .../directory-sync/workspace-replica.ts | 9 + packages/app/src/runtime/host-runtime.test.ts | 349 +-- packages/app/src/runtime/host-runtime.ts | 102 +- .../src/runtime/replica-cache/index.test.ts | 550 +---- .../app/src/runtime/replica-cache/index.ts | 871 ++++--- .../runtime/replica-cache/row-store-sqlite.ts | 17 +- .../replica-cache/row-store.contract.ts | 21 +- .../src/runtime/replica-cache/row-store.ts | 2 +- .../replica-cache/row-store.web.test.ts | 45 +- .../runtime/replica-cache/row-store.web.ts | 65 +- .../screens/workspace/workspace-screen.tsx | 33 +- packages/app/src/stores/session-store.test.ts | 218 +- packages/app/src/stores/session-store.ts | 615 ++++- .../legacy-unverifiable-timeline.json | 62 - .../mixed-unpositioned-message.json | 44 - .../fetch-agent-timeline-once.test.ts | 30 + .../src/timeline/fetch-agent-timeline-once.ts | 31 + .../app/src/timeline/replica.contract.test.ts | 2171 ----------------- packages/app/src/timeline/replica.test.ts | 812 ++---- packages/app/src/timeline/replica.ts | 752 ------ .../timeline/session-stream-reducers.test.ts | 856 +------ .../src/timeline/session-stream-reducers.ts | 922 ++++--- .../src/timeline/viewed-timeline-sync.test.ts | 327 +-- .../app/src/timeline/viewed-timeline-sync.ts | 641 +++-- packages/app/src/types/stream.test.ts | 117 - packages/app/src/types/stream.ts | 587 ++--- .../src/utils/agent-directory-sync.test.ts | 100 +- .../app/src/utils/agent-directory-sync.ts | 3 - 52 files changed, 3422 insertions(+), 8638 deletions(-) delete mode 100644 packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json delete mode 100644 packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json create mode 100644 packages/app/src/timeline/fetch-agent-timeline-once.test.ts create mode 100644 packages/app/src/timeline/fetch-agent-timeline-once.ts delete mode 100644 packages/app/src/timeline/replica.contract.test.ts delete mode 100644 packages/app/src/timeline/replica.ts diff --git a/docs/architecture.md b/docs/architecture.md index e83e3d0539..134e8f3532 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/data-model.md b/docs/data-model.md index 7e05a6d1de..5bd96f5252 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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 diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index caf7dea383..5d3464a30b 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -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` diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index ab8a2f85bf..194fe43d1d 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -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(); diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index 9becf359df..4119f7c692 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -94,18 +94,14 @@ interface PopoverFrameRecorderWindow extends Window { __stopComposerAutocompleteFrameRecorder?: () => void; } -async function expectAutocompleteCoveredAtPoint(page: Page, x: number, y: number): Promise { - 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 { @@ -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(); } diff --git a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts index 77e2a80998..f2ee89c12f 100644 --- a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts @@ -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 { - 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); diff --git a/packages/app/e2e/browser/sidebar-model-b.spec.ts b/packages/app/e2e/browser/sidebar-model-b.spec.ts index 0c7c84b39b..33e6ff8ddc 100644 --- a/packages/app/e2e/browser/sidebar-model-b.spec.ts +++ b/packages/app/e2e/browser/sidebar-model-b.spec.ts @@ -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( diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 11cc4bc3c3..91b6867cf0 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -494,19 +494,18 @@ export async function holdAgentOlderTimelinePages( }; } -export async function delayAgentBootstrapTimelineResponse( +export async function delayAgentBootstrapTailResponse( page: Page, agentId: string, ): Promise { - 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((resolve) => { - resolveDelayedBootstrap = resolve; + const delayedTail = new Promise((resolve) => { + resolveDelayedTail = resolve; }); const delayedCatchUp = new Promise((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, }; } diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index c6afe03736..0cdd34857c 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -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 { - return delayAgentBootstrapTimelineResponse(page, agent.agentId); + return delayAgentBootstrapTailResponse(page, agent.agentId); } export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise { diff --git a/packages/app/e2e/support/helpers/timeline-resume.ts b/packages/app/e2e/support/helpers/timeline-resume.ts index e397d17325..73c921776c 100644 --- a/packages/app/e2e/support/helpers/timeline-resume.ts +++ b/packages/app/e2e/support/helpers/timeline-resume.ts @@ -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, diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 0ad0394143..7f66a07b25 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -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, ); diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 929b3e1be0..8587fa3db6 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -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", diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index 8f708744c0..d1cfce1161 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -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({ 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" }); } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index a1c97957aa..84de49a3b9 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1484,10 +1484,7 @@ function ComposerContentImpl({ supportsForgeAttachments: supportsForgeSearch, }), encodeImages, - submission: createMessageSubmissionWriter( - serverId, - getHostRuntimeStore().getTimelineReplica(serverId), - ), + submission: createMessageSubmissionWriter(serverId), activeTurnBehavior, activeTurnId: activeTurnBehavior === "steer" diff --git a/packages/app/src/composer/submission/writer.test.ts b/packages/app/src/composer/submission/writer.test.ts index 1c76186615..3ee174d554 100644 --- a/packages/app/src/composer/submission/writer.test.ts +++ b/packages/app/src/composer/submission/writer.test.ts @@ -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"]); }); diff --git a/packages/app/src/composer/submission/writer.ts b/packages/app/src/composer/submission/writer.ts index 5cef427fae..f8b40034b6 100644 --- a/packages/app/src/composer/submission/writer.ts +++ b/packages/app/src/composer/submission/writer.ts @@ -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); +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index d484169734..fff3a0f496 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -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 | null>(null); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); + const forcedTimelineTailReplacements = useRef(new Set()); const viewedTimelineSyncRef = useRef(null); const audioOutputBuffersRef = useRef>(new Map()); const activeAudioGroupsRef = useRef>(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, diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index 899f95c631..f2726fba02 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -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({ diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index 1f4fdc3cc8..d7deff40a7 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -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); }); }); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index ad41e2ae7c..1bdf3ea3de 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -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(); private readonly members = new Set(); + private readonly pendingCacheReads = new Set(); 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 { 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): 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(); + 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); } diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 030ddb1225..5fbeb3d3e9 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -1,3 +1,10 @@ +import { DatabaseSync } from "node:sqlite"; +import { ReplicaCache } from "@/runtime/replica-cache"; +import { + createSqliteReplicaRowStore, + type ReplicaSqliteConnection, + type SqliteValue, +} from "@/runtime/replica-cache/row-store-sqlite"; import { afterEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; @@ -6,17 +13,9 @@ import { normalizeWorkspaceDescriptor, useSessionStore, } from "@/stores/session-store"; -import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors"; -import { ReplicaCache, type DirectoryReplicaMutation } from "@/runtime/replica-cache"; -import { createTimelineReplica } from "@/timeline/replica"; -import { createViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; -import type { - ReplicaHostRows, - ReplicaRow, - ReplicaRowChanges, - ReplicaRowStore, -} from "@/runtime/replica-cache/row-store"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { DirectoryRefreshSupersededError, DirectorySync, @@ -26,7 +25,6 @@ import { type WorkspaceFetchResult = Awaited>; type ProjectListResult = Awaited>; type AgentFetchResult = Awaited>; -type TimelineFetchResult = Awaited>; class FakeDirectoryClient { fetchAgentsCalls = 0; @@ -38,10 +36,6 @@ class FakeDirectoryClient { projectResult: ProjectListResult | null = null; private pendingAgentFetch: Promise | null = null; private pendingWorkspaceFetch: Promise | null = null; - private readonly pendingTimelineFetches: Array<{ - promise: Promise; - resolve(result: TimelineFetchResult): void; - }> = []; private readonly handlers = new Map< SessionOutboundMessage["type"], Set<(message: SessionOutboundMessage) => void> @@ -111,21 +105,6 @@ class FakeDirectoryClient { }; } - fetchAgentTimeline(): Promise { - let resolve!: (result: TimelineFetchResult) => void; - const promise = new Promise((complete) => { - resolve = complete; - }); - this.pendingTimelineFetches.push({ promise, resolve }); - return promise; - } - - nextTimelineFetch(): { resolve(result: TimelineFetchResult): void } { - const pending = this.pendingTimelineFetches.shift(); - if (!pending) throw new Error("Expected a pending timeline fetch"); - return { resolve: pending.resolve }; - } - async listProjects(options?: unknown): Promise { this.listProjectsCalls += 1; this.lastProjectOptions = options; @@ -149,102 +128,20 @@ class FakeDirectoryClient { } } -class MemoryRowStore implements ReplicaRowStore { - readonly rows = new Map(); - readAllGate: Promise | null = null; - - private key(row: Pick): string { - return `${row.serverId}:${row.kind}:${row.id}`; - } - - async open(): Promise {} - - async read( - serverIds: readonly string[], - kinds: readonly ReplicaRow["kind"][], - ids?: readonly string[], - ): Promise { - await this.readAllGate; - return [...this.rows.values()].filter( - (row) => - serverIds.includes(row.serverId) && - kinds.includes(row.kind) && - (!ids || ids.includes(row.id)), - ); - } - - async readAll(): Promise { - await this.readAllGate; - const hosts = new Map(); - for (const row of this.rows.values()) { - hosts.set(row.serverId, [...(hosts.get(row.serverId) ?? []), row]); - } - return [...hosts].map(([serverId, rows]) => ({ serverId, rows })); - } - - async apply(changes: ReplicaRowChanges): Promise { - for (const key of changes.deletes) this.rows.delete(this.key(key)); - for (const row of changes.upserts) this.rows.set(this.key(row), row); - } - - async deleteHost(serverId: string): Promise { - for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key); - } - - async renameHost(oldServerId: string, newServerId: string): Promise { - for (const [key, row] of this.rows) { - if (row.serverId !== oldServerId) continue; - this.rows.delete(key); - const renamed = { ...row, serverId: newServerId }; - this.rows.set(this.key(renamed), renamed); - } - } - - async clear(): Promise { - this.rows.clear(); - } -} - -// A saved directory on disk, read back through the real storage owner with its load gated. -async function savedDirectory( - serverId: string, - directory: Parameters[1], -): Promise<{ cache: ReplicaCache; releaseLoad: () => void }> { - const rowStore = new MemoryRowStore(); - const writer = new ReplicaCache(rowStore, { clearLegacyCache: async () => undefined }); - writer.setHosts([serverId]); - writer.replaceDirectoryBaseline(serverId, directory); - await writer.flush(); - let releaseLoad!: () => void; - rowStore.readAllGate = new Promise((resolve) => { - releaseLoad = resolve; - }); - const cache = new ReplicaCache(rowStore, { clearLegacyCache: async () => undefined }); - cache.setHosts([serverId]); - return { cache, releaseLoad }; -} - -const noopCallbacks = { - onTimelineRequest: () => () => undefined, - onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, - markAgentLoading: () => undefined, - markAgentReady: () => undefined, - markAgentError: () => undefined, -}; - const serverIds = new Set(); -function createDirectory( - serverId: string, - callbacks: ConstructorParameters[1] = noopCallbacks, -): { +function createDirectory(serverId: string): { client: FakeDirectoryClient; directory: DirectorySync; } { serverIds.add(serverId); const client = new FakeDirectoryClient(); - const directory = new DirectorySync(serverId, callbacks); + const directory = new DirectorySync(serverId, { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }); directory.connectionChanged({ client: client as unknown as DaemonClient, status: "online", @@ -286,343 +183,12 @@ function createAgent(serverId: string, id: string) { }; } -it.each(["snapshot", "changes", "live"] as const)( - "%s scope exclusion retains the saved page but invalidates old work; deletion removes it", - async (exclusion) => { - const serverId = `timeline-scope-${exclusion}`; - serverIds.add(serverId); - useSessionStore.getState().initializeSession(serverId, null); - const rows = new MemoryRowStore(); - const cache = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); - cache.setHosts([serverId]); - const saved = { - agentId: "agent", - range: { epoch: "epoch", startSeq: 1, endSeq: 1 }, - hasOlder: false, - 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 }, - }, - ], - }; - cache.commitTimeline(serverId, "agent", saved); - await cache.flush(); - const replica = createTimelineReplica({ serverId, storage: cache }); - const viewed = createViewedTimelineOwner({ - serverId, - replica, - replaceDemandedAgentIds: () => undefined, - drainQueuedAgentMessage: () => undefined, - }); - const directory = new DirectorySync( - serverId, - { - ...noopCallbacks, - onAgentRemoved: (...args) => viewed.removeAgent(...args), - onAgentAccepted: (id) => viewed.acceptAgent(id), - }, - cache, - ); - const client = new FakeDirectoryClient(); - useSessionStore.getState().updateSessionClient(serverId, client as unknown as DaemonClient, 1); - useSessionStore.getState().updateSessionServerInfo(serverId, { - serverId, - hostname: null, - version: "test", - features: { workspaceMultiplicity: true, directorySync: true }, - }); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "online", - source: { clientGeneration: 1, connectionEpoch: 1 }, - }); - directory.acceptAgent(createAgent(serverId, "agent")); - await replica.prepare("agent"); - const stale = directory.fetchTimeline("agent", { - direction: "tail", - limit: 40, - projection: "projected", - }); - const staleReply = client.nextTimelineFetch(); - if (exclusion === "live") { - client.emit({ type: "agent_update", payload: { kind: "remove", agentId: "agent" } }); - } else { - const reply = client.holdAgentFetch(); - const refreshing = directory.refreshAgents(); - await expect.poll(() => client.fetchAgentsCalls).toBe(1); - reply({ - requestId: "scope", - entries: [], - pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, - ...(exclusion === "changes" - ? { - sync: { - generation: "g", - headSeq: 1, - mode: "changes" as const, - removals: [{ id: "agent", seq: 1 }], - }, - } - : {}), - }); - await refreshing; - } - expect(useSessionStore.getState().sessions[serverId]?.agentStreamTail.has("agent")).toBe(false); - expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(false); - 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: "late work", messageId: "late" }, - }, - }); - viewed.flushStreamAgent("agent"); - expect(useSessionStore.getState().sessions[serverId]?.agentStreamTail.has("agent")).toBe(false); - await cache.flush(); - const reopened = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); - reopened.setHosts([serverId]); - expect(await reopened.readTimeline(serverId, "agent")).toEqual(saved); - directory.acceptAgent(createAgent(serverId, "agent")); - await replica.prepare("agent"); - staleReply.resolve({ agent: null, hasNewer: false } as TimelineFetchResult); - await expect(stale).rejects.toBeInstanceOf(DirectoryRefreshSupersededError); - expect(useSessionStore.getState().sessions[serverId]?.agentStreamTail.get("agent")).toEqual( - saved.items, - ); - // Deletion must also remove a saved page whose agent is already out of scope. - client.emit({ type: "agent_update", payload: { kind: "remove", agentId: "agent" } }); - client.emit({ type: "agent_deleted", payload: { agentId: "agent", requestId: "delete" } }); - // The subsequent directory remove must not recreate the deleted row. - client.emit({ type: "agent_update", payload: { kind: "remove", agentId: "agent" } }); - await cache.flush(); - const afterDeletion = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); - afterDeletion.setHosts([serverId]); - expect(await afterDeletion.readTimeline(serverId, "agent")).toBeUndefined(); - viewed.dispose(); - directory.dispose(); - }, -); - -it.each(["snapshot-first", "page-first", "live-first"] as const)( - "first admission preserves the first timeline page (%s)", - async (ordering) => { - const serverId = `first-admission-${ordering}`; - serverIds.add(serverId); - useSessionStore.getState().initializeSession(serverId, null); - const rows = new MemoryRowStore(); - const cache = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); - cache.setHosts([serverId]); - const replica = createTimelineReplica({ serverId, storage: cache }); - const viewed = createViewedTimelineOwner({ - serverId, - replica, - replaceDemandedAgentIds: () => undefined, - drainQueuedAgentMessage: () => undefined, - }); - let applications = 0; - const { directory, client } = createDirectory(serverId, { - ...noopCallbacks, - onAgentAccepted: (id) => viewed.acceptAgent(id), - onTimelineRequest: (id) => { - const apply = viewed.beginTimelineRequest(id); - return (page) => { - applications++; - apply(page); - }; - }, - }); - useSessionStore.getState().updateSessionClient(serverId, client as unknown as DaemonClient, 1); - useSessionStore.getState().updateSessionServerInfo(serverId, { - serverId, - hostname: null, - version: "test", - features: { workspaceMultiplicity: true }, - }); - await directory.ready(); - const request = { direction: "tail" as const, limit: 40, projection: "projected" as const }; - const fetching = directory.fetchTimeline("agent", request); - const concurrent = directory.fetchTimeline("agent", request); - const reply = client.nextTimelineFetch(); - const agent = projectAgentSnapshot(createAgent(serverId, "agent")); - const page: TimelineFetchResult = { - requestId: "first", - agentId: "agent", - agent, - 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 page", messageId: "first" }, - timestamp: "2026-09-06T00:00:00Z", - seqStart: 1, - seqEnd: 1, - sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], - collapsed: [], - }, - ], - }; - if (ordering === "page-first") { - reply.resolve(page); - await fetching; - } - if (ordering === "live-first") - client.emit({ type: "agent_update", payload: { kind: "upsert", agent } }); - else { - const release = client.holdAgentFetch(); - const refreshing = directory.refreshAgents(); - await expect.poll(() => client.fetchAgentsCalls).toBe(1); - release({ - requestId: "directory", - entries: [ - { - agent, - project: { - projectKey: "/repo", - projectName: "repo", - checkout: { - cwd: "/repo", - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }, - }, - ], - pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, - }); - await refreshing; - } - if (ordering !== "page-first") reply.resolve(page); - await expect(Promise.all([fetching, concurrent])).resolves.toEqual([page, page]); - expect(applications).toBe(1); - expect(() => client.nextTimelineFetch()).toThrow("Expected a pending timeline fetch"); - expect( - useSessionStore.getState().sessions[serverId]?.agentStreamTail.get("agent")?.[0], - ).toMatchObject({ text: "first page" }); - viewed.dispose(); - directory.dispose(); - }, -); - -it("rejects an old timeline reply after authoritative agent re-entry", async () => { - const serverId = "timeline-lifetime"; - serverIds.add(serverId); - useSessionStore.getState().initializeSession(serverId, null); - const client = new FakeDirectoryClient(); - const lifetimes: string[] = []; - const directory = new DirectorySync(serverId, { - onTimelineRequest: () => () => undefined, - onAgentStoppedRunning: () => undefined, - onAgentRemoved: (agentId) => lifetimes.push(`remove:${agentId}`), - onAgentAccepted: (agentId) => lifetimes.push(`accept:${agentId}`), - markAgentLoading: () => undefined, - markAgentReady: () => undefined, - markAgentError: () => undefined, - }); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "online", - source: { clientGeneration: 1, connectionEpoch: 1 }, - }); - directory.acceptAgent(createAgent(serverId, "agent")); - const request = { direction: "tail" as const, limit: 40, projection: "projected" as const }; - const oldFetch = directory.fetchTimeline("agent", request); - const oldReply = client.nextTimelineFetch(); - - directory.removeAgent("agent"); - const excludedFetch = directory.fetchTimeline("agent", request); - const excludedReply = client.nextTimelineFetch(); - directory.acceptAgent(createAgent(serverId, "agent")); - const freshFetch = directory.fetchTimeline("agent", request); - const freshReply = client.nextTimelineFetch(); - const page = { agent: null, hasNewer: false } as TimelineFetchResult; - - oldReply.resolve(page); - await expect(oldFetch).rejects.toBeInstanceOf(DirectoryRefreshSupersededError); - excludedReply.resolve(page); - await expect(excludedFetch).rejects.toBeInstanceOf(DirectoryRefreshSupersededError); - freshReply.resolve(page); - await expect(freshFetch).resolves.toBe(page); - expect(lifetimes).toEqual(["accept:agent", "remove:agent", "accept:agent"]); -}); - afterEach(() => { for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId); serverIds.clear(); }); describe("DirectorySync session readiness", () => { - it("restores other cached workspaces when a live removal arrives during startup", async () => { - const serverId = "cache-with-live-removal"; - serverIds.add(serverId); - const client = new FakeDirectoryClient(); - const workspace = normalizeWorkspaceDescriptor({ - id: "cached-workspace", - projectId: "cached-project", - projectDisplayName: "Cached project", - projectRootPath: "/repo/cached", - workspaceDirectory: "/repo/cached", - projectKind: "git", - workspaceKind: "local_checkout", - name: "cached", - status: "done", - statusEnteredAt: null, - activityAt: null, - archivingAt: null, - diffStat: null, - scripts: [], - }); - const { cache, releaseLoad } = await savedDirectory(serverId, { - agents: new Map(), - projects: new Map(), - workspaces: new Map([ - [workspace.id, workspace], - ["deleted-live", { ...workspace, id: "deleted-live" }], - ]), - }); - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - const directory = new DirectorySync(serverId, noopCallbacks, cache); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "online", - source: { clientGeneration: 1, connectionEpoch: 1 }, - }); - client.emit({ type: "workspace_update", payload: { kind: "remove", id: "deleted-live" } }); - releaseLoad(); - await directory.ready(); - - expect([...useSessionStore.getState().sessions[serverId]!.workspaces.keys()]).toEqual([ - workspace.id, - ]); - expect(selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [serverId])).toEqual([ - serverId, - ]); - expect(client.fetchWorkspacesCalls).toBe(0); - directory.dispose(); - }); - it("restores the cached directory before network demand", async () => { const serverId = "offline-cached-directory"; serverIds.add(serverId); @@ -649,24 +215,32 @@ describe("DirectorySync session readiness", () => { projectRootPath: "/repo/cached", projectKind: "git", }); - const cachedAgent = createAgent(serverId, "agent-1"); - cachedAgent.workspaceId = cachedWorkspace.id; - const directory = new DirectorySync(serverId, noopCallbacks, { - readDirectory: async () => ({ - agents: new Map([[cachedAgent.id, cachedAgent]]), - workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), - projects: new Map([[cachedProject.projectId, cachedProject]]), - }), - commitDirectoryMutations: () => undefined, - }); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), + projects: new Map([[cachedProject.projectId, cachedProject]]), + }), + commitDirectoryMutations: () => undefined, + }, + ); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - await directory.ready(); + await directory.restoreCachedDirectory(); - const session = useSessionStore.getState().sessions[serverId]; - expect(session?.hasHydratedAgents).toBe(false); - expect(session?.agents.get(cachedAgent.id)).toEqual(cachedAgent); - expect(session?.workspaces.get(cachedWorkspace.id)).toEqual(cachedWorkspace); + expect( + useSessionStore.getState().sessions[serverId]?.workspaces.get(cachedWorkspace.id), + ).toEqual(cachedWorkspace); expect(selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [serverId])).toEqual([ serverId, ]); @@ -675,7 +249,62 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - it("persists accepted script status updates through the directory owner", async () => { + it("prepares a cached agent workspace route without sidebar demand", async () => { + const serverId = "cached-agent-route"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const cachedAgent = createAgent(serverId, "agent-1"); + const cachedWorkspace = normalizeWorkspaceDescriptor({ + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Paseo", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }); + cachedAgent.workspaceId = cachedWorkspace.id; + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => cachedAgent, + readWorkspace: async () => ({ workspace: cachedWorkspace }), + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + }), + commitDirectoryMutations: () => undefined, + }, + ); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + + await directory.prepareAgentRoute(cachedAgent.id); + + const session = useSessionStore.getState().sessions[serverId]; + expect(session?.hasHydratedAgents).toBe(false); + expect(session?.hasWorkspaceDirectorySnapshot).toBe(false); + expect(session?.agents.get(cachedAgent.id)).toEqual(cachedAgent); + expect(session?.workspaces.get(cachedWorkspace.id)).toEqual(cachedWorkspace); + expect(client.fetchAgentsCalls).toBe(0); + expect(client.fetchWorkspacesCalls).toBe(0); + directory.dispose(); + }); + + it("persists accepted script status updates through the directory owner", () => { const serverId = "script-status-owner"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -683,14 +312,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { - onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { + readAgent: async () => undefined, + readWorkspace: async () => undefined, readDirectory: async () => ({ agents: new Map(), workspaces: new Map(), @@ -722,7 +351,6 @@ describe("DirectorySync session readiness", () => { }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); - await directory.ready(); directory.acceptWorkspaces([workspace]); commits.length = 0; @@ -850,14 +478,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { - onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { + readAgent: async () => undefined, + readWorkspace: async () => undefined, readDirectory: async () => { cacheReads += 1; return { @@ -932,14 +560,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { - onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { + readAgent: async () => undefined, + readWorkspace: async () => undefined, readDirectory: async () => ({ agents: new Map(), workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), @@ -983,7 +611,7 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - it("advances the saved cursor only through completed catch-up", async () => { + it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => { const serverId = "late-directory-cache"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -998,14 +626,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { - onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { + readAgent: async () => undefined, + readWorkspace: async () => undefined, readDirectory: () => cacheRead, commitDirectoryMutations: () => undefined, }, @@ -1024,95 +652,133 @@ describe("DirectorySync session readiness", () => { features: { workspaceMultiplicity: true, directorySync: true }, }); - const releaseNetwork = client.holdWorkspaceFetch(); const refresh = directory.refreshWorkspaces(); await Promise.resolve(); client.emit({ type: "workspace_update", - payload: { kind: "remove", id: "deleted-live", generation: "g", seq: 20 }, + payload: { kind: "remove", id: "deleted-live" }, }); releaseCache({ agents: new Map(), workspaces: new Map(), projects: new Map(), - checkpoint: { workspaces: { generation: "g", afterSeq: 7 } }, - }); - releaseNetwork({ - requestId: "catch-up", - entries: [], - emptyProjects: [], - pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, - sync: { generation: "g", headSeq: 20, mode: "changes", removals: [] }, + checkpoint: { workspaces: { generation: "stale", afterSeq: 99 } }, }); await refresh; - expect(client.lastWorkspaceOptions).toMatchObject({ - sync: { generation: "g", afterSeq: 7 }, - }); - client.emit({ - type: "workspace_update", - payload: { kind: "remove", id: "another-removal", generation: "g", seq: 21 }, - }); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "offline", - source: { clientGeneration: 1, connectionEpoch: 1 }, - }); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "online", - source: { clientGeneration: 1, connectionEpoch: 2 }, - }); - client.emit({ - type: "workspace_update", - payload: { kind: "remove", id: "after-offline-gap", generation: "g", seq: 40 }, - }); - await directory.refreshWorkspaces(); - expect(client.lastWorkspaceOptions).toMatchObject({ - sync: { generation: "g", afterSeq: 20 }, - }); + expect(client.lastWorkspaceOptions).not.toHaveProperty("sync.generation"); directory.dispose(); }); - it("does not resurrect an agent deleted while the baseline is loading", async () => { + it("does not resurrect an agent deleted while its targeted cache row is loading", async () => { const serverId = "late-agent-cache"; serverIds.add(serverId); const client = new FakeDirectoryClient(); - const cachedAgent = createAgent(serverId, "agent-1"); - const { cache, releaseLoad } = await savedDirectory(serverId, { - agents: new Map([[cachedAgent.id, cachedAgent]]), - workspaces: new Map(), - projects: new Map(), + const cachedAgent = { + ...normalizeAgentSnapshot( + { + id: "agent-1", + provider: "codex", + cwd: "/repo", + model: null, + createdAt: "2026-08-26T00:00:00.000Z", + updatedAt: "2026-08-26T00:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Cached", + labels: {}, + }, + serverId, + ), + projectPlacement: null, + }; + let releaseAgent!: (agent: typeof cachedAgent) => void; + const agentRead = new Promise((resolve) => { + releaseAgent = resolve; }); - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - const directory = new DirectorySync(serverId, noopCallbacks, cache); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: () => agentRead, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + }), + commitDirectoryMutations: () => undefined, + }, + ); directory.connectionChanged({ client: client as unknown as DaemonClient, status: "online", source: { clientGeneration: 1, connectionEpoch: 1 }, }); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + const load = directory.loadCachedAgent(cachedAgent.id); client.emit({ type: "agent_deleted", payload: { agentId: cachedAgent.id, requestId: "delete-live" }, }); - releaseLoad(); - await directory.ready(); + releaseAgent(cachedAgent); + await load; expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false); directory.dispose(); }); - it("requests the authoritative snapshot only after the baseline is restored", async () => { + it("does not resurrect an agent removed by an authoritative snapshot while cache loads", async () => { const serverId = "late-agent-authoritative-snapshot"; serverIds.add(serverId); const client = new FakeDirectoryClient(); const cachedAgent = createAgent(serverId, "agent-1"); - const { cache, releaseLoad } = await savedDirectory(serverId, { - agents: new Map([[cachedAgent.id, cachedAgent]]), - workspaces: new Map(), - projects: new Map(), - checkpoint: { agents: { generation: "g", afterSeq: 5 } }, + const releaseNetwork = client.holdAgentFetch(); + let releaseAgent!: (agent: typeof cachedAgent) => void; + const agentRead = new Promise((resolve) => { + releaseAgent = resolve; + }); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: () => agentRead, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + }), + commitDirectoryMutations: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); @@ -1122,22 +788,18 @@ describe("DirectorySync session readiness", () => { version: "test", features: { directorySync: true, workspaceMultiplicity: true }, }); - const directory = new DirectorySync(serverId, noopCallbacks, cache); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "online", - source: { clientGeneration: 1, connectionEpoch: 1 }, - }); const refresh = directory.refreshAgents(); - await Promise.resolve(); - expect(client.fetchAgentsCalls).toBe(0); - releaseLoad(); - await refresh; - - expect(client.lastAgentOptions).toMatchObject({ - sync: { generation: "g", afterSeq: 5 }, + const load = directory.loadCachedAgent(cachedAgent.id); + releaseNetwork({ + requestId: "authoritative-snapshot", + entries: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, }); + await refresh; + releaseAgent(cachedAgent); + await load; + expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false); directory.dispose(); }); @@ -1149,14 +811,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { - onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { + readAgent: async () => undefined, + readWorkspace: async () => undefined, readDirectory: async () => ({ agents: new Map(), workspaces: new Map(), @@ -1274,14 +936,14 @@ describe("DirectorySync session readiness", () => { const directory = new DirectorySync( serverId, { - onTimelineRequest: () => () => undefined, onAgentStoppedRunning: () => undefined, - onAgentRemoved: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, }, { + readAgent: async () => undefined, + readWorkspace: async () => undefined, readDirectory: async () => ({ agents: new Map(), workspaces: new Map(), @@ -1477,3 +1139,122 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); }); + +function createSqliteCache() { + const database = new DatabaseSync(":memory:"); + let beforeRead = async () => {}; + const connection: ReplicaSqliteConnection = { + async exec(sql) { + database.exec(sql); + }, + async run(sql, params = []) { + database.prepare(sql).run(...params); + }, + async all(sql: string, params: readonly SqliteValue[] = []) { + const rows = database.prepare(sql).all(...params) as Row[]; + if (sql.includes("FROM rows") && sql.includes("WHERE")) await beforeRead(); + return rows; + }, + async transaction(operation) { + database.exec("BEGIN IMMEDIATE"); + try { + await operation(connection); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + }, + }; + const storage = createSqliteReplicaRowStore({ open: async () => connection }, 1); + const cache = new ReplicaCache(storage, { clearLegacyCache: async () => {} }); + return { + cache, + database, + holdRead: (operation: () => Promise) => { + beforeRead = operation; + }, + }; +} + +it("fills every cached workspace beneath live updates received during the SQLite read", async () => { + const serverId = "sqlite-directory-race"; + serverIds.add(serverId); + const { cache, database, holdRead } = createSqliteCache(); + cache.setHosts([serverId]); + const project = normalizeProjectDescriptor({ + projectId: "project", + projectDisplayName: "Cached", + projectRootPath: "/repo", + projectKind: "git", + }); + const workspaces = ["first", "second", "third"].map((id) => + normalizeWorkspaceDescriptor({ + id, + projectId: "project", + projectDisplayName: "Cached", + projectRootPath: "/repo", + workspaceDirectory: `/repo/${id}`, + projectKind: "git", + workspaceKind: "local_checkout", + name: id, + status: "done", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }), + ); + cache.replaceDirectoryBaseline(serverId, { + agents: new Map([["agent", createAgent(serverId, "agent")]]), + workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace])), + projects: new Map([[project.projectId, project]]), + }); + await cache.flush(); + const client = new FakeDirectoryClient(); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => {}, + markAgentLoading: () => {}, + markAgentReady: () => {}, + markAgentError: () => {}, + }, + cache, + ); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + let updated = false; + holdRead(async () => { + if (updated) return; + updated = true; + client.emit({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: { ...workspaces[0], name: "Live", activityAt: null, statusEnteredAt: null }, + }, + }); + }); + await directory.restoreCachedDirectory(); + const session = useSessionStore.getState().sessions[serverId]; + expect( + [...session.workspaces.values()] + .map(({ id, name }) => ({ id, name })) + .sort((a, b) => a.id.localeCompare(b.id)), + ).toEqual([ + { id: "first", name: "Live" }, + { id: "second", name: "second" }, + { id: "third", name: "third" }, + ]); + expect(session.agents.get("agent")?.title).toBe("Cached"); + expect(session.hasWorkspaceDirectorySnapshot).toBe(true); + directory.dispose(); + await cache.flush(); + database.close(); +}); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 0894de257a..a490421ceb 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -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>; - const PAGE_LIMIT = 200; const AGENT_SORT: NonNullable = [ { key: "updated_at", direction: "desc" }, @@ -108,6 +108,8 @@ export interface DirectoryConnection { } export interface DirectoryCheckpointStorage { + readAgent(serverId: string, agentId: string): Promise; + readWorkspace(serverId: string, workspaceId: string): Promise; readDirectory(serverId: string): Promise; commitDirectoryMutations( serverId: string, @@ -137,7 +139,6 @@ export class DirectorySync { WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta >(); - private readonly timelineRequests = new Map>(); 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; + private cacheLoad: Promise | null = null; + private cacheAccepted = false; + private revision = 0; + private workspaceRevision = 0; private readonly routeDemandIds = new Set(); private readonly fullDemandSources = new Set(); private demandRefresh: Promise | 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 { - 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 { 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 { + async loadCachedAgent(agentId: string): Promise { 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 { + 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 { + await this.loadCachedWorkspace(workspaceId); + } + + private async loadCachedWorkspace(workspaceId: string): Promise { + 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 { + 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 { + return this.loadCachedDirectory(); } applyAgentTurnLiveness( @@ -357,36 +412,33 @@ export class DirectorySync { async fetchTimeline( agentId: string, request: Parameters[1], - replaceTail = false, - ): Promise { - const { client, source } = this.requireOnline(); + ): Promise>> { + 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 { + return this.refreshAgentsInternal(input, true); + } + + private async refreshAgentsInternal( + input: RefreshAgentDirectoryInput, + loadDirectoryCache: boolean, + ): Promise { + 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 { + return this.refreshWorkspacesInternal(input, true); + } + + private async refreshWorkspacesInternal( + input: { subscribe?: boolean } | undefined, + loadDirectoryCache: boolean, + ): Promise { + 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; } diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts index b4504d9c8b..0566da5aaf 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -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 { 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 = (current: Map): Map => { 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); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts index ffb2023736..84f08b22fc 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -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 { diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index 5e8951edf3..d229c196a2 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -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[], diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index a7ba9eb784..b77d53f646 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -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 > = []; - public timelineCalls = 0; - public timelineResponse: ReturnType | null = null; - fetchAgentTimeline(): ReturnType { - this.timelineCalls++; - if (!this.timelineResponse) throw new Error("Missing timeline response"); - return this.timelineResponse; - } public sentAgentMessages: Array> = []; public sendAgentMessageFailures: Error[] = []; public sendAgentMessageResponses: Promise[] = []; @@ -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(); 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> = { - 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>>(); - 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(); - const release = Promise.withResolvers(); - 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", diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 7ab9902597..ad33968740 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -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(); private nextCancellationRequestId = 0; private timelineReplicaByServer = new Map(); - private viewedTimelineByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootPromise: Promise | 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 { + 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 { + 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 { + 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[1], - replaceTail = false, ): Promise>> { 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 { diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 2a603490c6..df6c3a0aa0 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -25,58 +25,41 @@ class MemoryStorage implements ReplicaRowStore { readonly rows = new Map(); 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 | null = null; - readAllGate: Promise | null = null; - writeGate: Promise | null = null; - renameGate: Promise | null = null; + onRead: (() => void) | null = null; private key(row: Pick): string { return `${row.serverId}:${row.kind}:${row.id}`; } - async open(): Promise { - if (this.nextOpenFailure) { - const error = this.nextOpenFailure; - this.nextOpenFailure = null; - throw error; - } - } + async open(): Promise {} async read( - serverIds: readonly string[], + serverId: string, kinds: readonly ReplicaRow["kind"][], ids?: readonly string[], ): Promise { - 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 { - this.readAlls += 1; - await this.readAllGate; - if (this.nextReadAllFailure) { - const error = this.nextReadAllFailure; - this.nextReadAllFailure = null; - throw error; - } const hosts = new Map(); 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 { 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 { - 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 { - 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>; - 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(); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index de02987c21..c1db2f8418 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { AgentStatusSchema, - AgentAttachmentSchema, AgentTimelineItemPayloadSchema, WorkspaceGitHubRuntimePayloadSchema, } from "@getpaseo/protocol/messages"; @@ -46,6 +45,11 @@ export interface CachedDirectory { checkpoint?: DirectoryCheckpoint; } +export interface CachedWorkspace { + workspace: WorkspaceDescriptor; + project?: ProjectDescriptor; +} + export interface DirectoryCursor { generation: string; afterSeq: number; @@ -65,32 +69,13 @@ export interface CachedTimeline { } const PERSIST_DELAY_MS = 1_000; +const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 32 * 1024 * 1024; const IsoDateSchema = z.iso.datetime(); const TimelinePositionSchema = z.strictObject({ epoch: z.string(), seq: z.number().int().nonnegative(), }); -const RowSourceSchema = z.strictObject({ - startSeq: z.number().int().nonnegative(), - chunks: z - .array( - z.strictObject({ - seq: z.number().int().nonnegative(), - offset: z.number().int().nonnegative(), - }), - ) - .optional(), -}); -const StoredImageSchema = z.strictObject({ - id: z.string(), - mimeType: z.string(), - storageType: z.enum(["web-indexeddb", "desktop-file", "native-file"]), - storageKey: z.string(), - fileName: z.string().nullable().optional(), - byteSize: z.number().nullable().optional(), - createdAt: z.number(), -}); const PluginTimelineDataSchema: z.ZodType = z.lazy(() => z.union([ z.null(), @@ -105,8 +90,6 @@ const PluginTimelineDataSchema: z.ZodType = z.lazy(() => const TimelineItemBaseShape = { id: z.string(), timelineCursor: TimelinePositionSchema.optional(), - // Absent on rows saved before source provenance existed. - source: RowSourceSchema.optional(), // COMPAT(active-turn-membership): absent on caches written before turn membership. turnId: z.string().optional(), timestamp: IsoDateSchema, @@ -132,8 +115,6 @@ const StoredTimelineItemSchema = z.discriminatedUnion("kind", [ z.strictObject({ ...TimelineItemBaseShape, kind: z.literal("user_message"), - images: z.array(StoredImageSchema).optional(), - attachments: z.array(AgentAttachmentSchema).optional(), clientMessageId: z.string().optional(), messageId: z.string().optional(), text: z.string(), @@ -398,7 +379,10 @@ const DirectoryCheckpointSchema = z.strictObject({ agents: DirectoryCursorSchema.optional(), }); -function deserializeTimeline(stored: StoredTimeline): CachedTimeline { +function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null { + if (!stored) { + return null; + } return { agentId: stored.agentId, items: stored.items.map(deserializeTimelineItem), @@ -411,7 +395,6 @@ function timelineBase(item: StreamItem) { return { id: item.id, ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), - ...(item.source ? { source: item.source } : {}), ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: item.timestamp.toISOString(), }; @@ -439,7 +422,13 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { const base = timelineBase(item); switch (item.kind) { case "user_message": - return { ...item, ...base }; + return { + ...base, + kind: item.kind, + ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + }; case "assistant_message": return { ...base, @@ -501,7 +490,6 @@ function deserializeTimelineItem(item: StoredTimelineItem): StreamItem { return { id: item.id, ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), - ...(item.source ? { source: item.source } : {}), ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: new Date(item.timestamp), kind: item.kind, @@ -521,13 +509,18 @@ function deserializeBuiltinTimelineItem( const base = { id: item.id, ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), - ...(item.source ? { source: item.source } : {}), ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: new Date(item.timestamp), }; switch (item.kind) { case "user_message": - return { ...item, ...base }; + return { + ...base, + kind: item.kind, + ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + }; case "assistant_message": return { ...base, @@ -734,43 +727,44 @@ function serializeProject(project: ProjectDescriptor): StoredProject { }; } -// A saved timeline is either certified or display-only. Certified rows keep the owner's restart page -// with its range, so the next launch resumes with one `after endSeq` page. Everything the range -// cannot vouch for (a discontiguous window or rows the schema cannot encode) falls back to a -// display-only tail that carries no coverage claim. -function isCertifiedTimeline(timeline: CachedTimeline): boolean { - const range = timeline.range; - return ( - range !== null && - range.retainedRanges === undefined && - timeline.items.every( - (item) => - (item.kind === "user_message" && isUnreconciledLocalUserMessage(item)) || - ((item.kind !== "tool_call" || item.payload.source === "agent") && - item.source !== undefined && - item.timelineCursor?.epoch === range.epoch && - item.timelineCursor.seq >= range.startSeq && - item.timelineCursor.seq <= range.endSeq), - ) - ); +function isTimelineItemStoredLosslessly(item: StreamItem): boolean { + switch (item.kind) { + case "user_message": + return (item.images?.length ?? 0) === 0 && (item.attachments?.length ?? 0) === 0; + case "tool_call": + return item.payload.source === "agent"; + default: + return true; + } } -function serializeTimelinePayload(timeline: CachedTimeline): string { - const items = timeline.items - .filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item)) - .map(serializeTimelineItem) - .filter((item) => item !== null); +function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null { + const canonicalItems = timeline.items.filter( + (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), + ); + const items = canonicalItems.map(serializeTimelineItem).filter((item) => item !== null); const range = timeline.range; - const certified = isCertifiedTimeline(timeline); - return JSON.stringify({ + const canPersistCoverage = + range !== null && + range.retainedRanges === undefined && + canonicalItems.length <= MAX_TIMELINE_ITEMS && + items.length === canonicalItems.length && + canonicalItems.every( + (item) => + isTimelineItemStoredLosslessly(item) && + item.timelineCursor?.epoch === range.epoch && + item.timelineCursor.seq >= range.startSeq && + item.timelineCursor.seq <= range.endSeq, + ) && + canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq); + return { agentId: timeline.agentId, - items, - range: - certified && range - ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } - : null, - hasOlder: certified && timeline.hasOlder, - } satisfies StoredTimeline); + items: items.slice(-MAX_TIMELINE_ITEMS), + range: canPersistCoverage + ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } + : null, + hasOlder: canPersistCoverage ? timeline.hasOlder : false, + }; } function rowKey(key: Pick): string { @@ -826,6 +820,12 @@ interface DirectoryReadAccumulator { checkpoint?: DirectoryCheckpoint; } +interface PendingReplicaChanges { + upserts: StructuredReplicaUpsert[]; + deletes: ReplicaRowKey[]; + baselines: Set; +} + function applyDirectoryRow( serverId: string, row: ReplicaRow, @@ -868,43 +868,22 @@ function directoryEntityForRow(row: ReplicaRow): keyof DirectoryCheckpoint | und return undefined; } -const DIRECTORY_KINDS: readonly ReplicaRowKind[] = ["agent", "workspace", "project", "checkpoint"]; - -interface PendingRow { - key: ReplicaRowKey; - // Null is an accepted deletion. - upsert: StructuredReplicaUpsert | null; -} - -interface PendingBatch { - rows: Map; - baselines: Map; -} - -// Scoped disk reads are overlaid by accepted commits, including changes that settled while -// a read was in flight. The full disk index serves write-behind budget accounting only. export class ReplicaCache { - private activeServerIds = new Set(); + private readonly activeServerIds = new Set(); + private readonly hostRevisions = new Map(); private readonly storedRows = new Map>(); private readonly hostBytes = new Map(); private readonly hostWriteOrder = new Map(); - private totalBytes = 0; - private readonly pendingRows = new Map(); - // Accepted commits overlay any disk read, including reads started before a write settled. - private readonly acceptedRows = new Map(); - private readonly acceptedBaselines = new Set(); - private readonly hostSources = new Map>(); - private readonly pendingBaselines = new Map(); + private pendingUpserts = new Map(); + private pendingDeletes = new Map(); + private pendingBaselines = new Set(); private readonly invalidatedHosts = new Set(); - // Renames queued behind the initial load, so the load keeps the old host's rows for them. - private readonly renamingHosts = new Map(); private readonly maxBytes: number; + private totalBytes = 0; private persistTimer: ReturnType | null = null; - // Memory readiness: the initial load followed by every host identity change, in order. - private image: Promise | null = null; - // Disk operations run in order; the initial load is the first of them once it starts. - private disk: Promise = Promise.resolve(); - private writes: Promise = Promise.resolve(); + private writeQueue: Promise = Promise.resolve(); + private preparePromise: Promise | null = null; + private storedIndexPromise: Promise | null = null; constructor( private readonly rowStore: ReplicaRowStore, @@ -916,8 +895,50 @@ export class ReplicaCache { private readonly clearLegacyCache: () => Promise; + async readAgent(serverId: string, agentId: string): Promise { + const rows = await this.readRows(serverId, ["agent"], [agentId]); + const row = rows[0]; + if (!row) return undefined; + try { + const stored = parseStoredPayload(StoredAgentSchema, row.payload); + if (stored.snapshot.id !== row.id) throw new Error("Replica agent row id mismatch"); + return deserializeAgent(serverId, stored); + } catch { + await this.deleteInvalidRow(row); + return undefined; + } + } + + async readWorkspace(serverId: string, workspaceId: string): Promise { + const workspaceRow = (await this.readRows(serverId, ["workspace"], [workspaceId]))[0]; + if (!workspaceRow) return undefined; + let workspace: WorkspaceDescriptor; + try { + const stored = parseStoredPayload(StoredWorkspaceSchema, workspaceRow.payload); + if (stored.id !== workspaceRow.id) throw new Error("Replica workspace row id mismatch"); + workspace = normalizeWorkspaceDescriptor(stored); + } catch { + await this.deleteInvalidRow(workspaceRow); + return undefined; + } + + let project: ProjectDescriptor | undefined; + const projectRow = (await this.readRows(serverId, ["project"], [workspace.projectId]))[0]; + if (projectRow) { + try { + const stored = parseStoredPayload(StoredProjectSchema, projectRow.payload); + if (stored.projectId !== projectRow.id) throw new Error("Replica project row id mismatch"); + project = normalizeProjectDescriptor(stored); + } catch { + await this.deleteInvalidRow(projectRow); + } + } + + return { workspace, ...(project ? { project } : {}) }; + } + async readDirectory(serverId: string): Promise { - const rows = await this.readRows(serverId, DIRECTORY_KINDS); + const rows = await this.readRows(serverId, ["agent", "workspace", "project", "checkpoint"]); const result: DirectoryReadAccumulator = { agents: new Map(), workspaces: new Map(), @@ -926,23 +947,6 @@ export class ReplicaCache { const invalidEntities = new Set(); const invalidRows: ReplicaRow[] = []; for (const row of rows) { - if ("value" in row) { - switch (row.kind) { - case "agent": - result.agents.set(row.id, { ...row.value, serverId }); - break; - case "workspace": - result.workspaces.set(row.id, row.value); - break; - case "project": - result.projects.set(row.id, row.value); - break; - case "checkpoint": - result.checkpoint = row.value; - break; - } - continue; - } try { applyDirectoryRow(serverId, row, result); } catch { @@ -956,100 +960,113 @@ export class ReplicaCache { for (const entity of invalidEntities) delete result.checkpoint[entity]; } if (invalidRows.length > 0) { - this.repairInvalidDirectoryRows(serverId, invalidRows, result.checkpoint); + await this.repairInvalidDirectoryRows(serverId, invalidRows, result.checkpoint); } return result; } async readTimeline(serverId: string, agentId: string): Promise { - const row = (await this.readRows(serverId, ["timeline"], [agentId]))[0]; + const rows = await this.readRows(serverId, ["timeline"], [agentId]); + const row = rows[0]; if (!row) return undefined; - let timeline: CachedTimeline; - if ("value" in row) { - if (row.kind !== "timeline") return undefined; - timeline = row.value; - } else { - try { - const stored = parseStoredPayload(StoredTimelineSchema, row.payload); - if (stored.agentId !== agentId) return undefined; - timeline = deserializeTimeline(stored); - } catch { - this.queueDelete(row); - this.schedulePersist(); - return undefined; - } + try { + const stored = parseStoredPayload(StoredTimelineSchema, row.payload); + if (stored.agentId !== agentId) return undefined; + return deserializeTimeline(stored) ?? undefined; + } catch { + await this.deleteInvalidRow(row); + return undefined; } - const certified = isCertifiedTimeline(timeline); - return { - ...timeline, - items: timeline.items.filter( - (item) => - !(item.kind === "user_message" && isUnreconciledLocalUserMessage(item)) && - !(item.kind === "tool_call" && item.payload.source !== "agent"), - ), - range: certified ? timeline.range : null, - hasOlder: certified && timeline.hasOlder, - }; } private async readRows( serverId: string, kinds: readonly ReplicaRowKind[], ids?: readonly string[], - ): Promise> { + ): Promise { if (!this.activeServerIds.has(serverId)) return []; - const sources = this.hostSources.get(serverId); - if (!sources) return []; - const sourceOrder = [...sources]; - const accepted = ids - ? kinds.every((kind) => - ids.every((id) => this.acceptedRows.has(pendingRowKey({ serverId, kind, id }))), - ) - : this.acceptedBaselines.has(serverId) && !kinds.includes("timeline"); - let stored: ReplicaRow[] = []; - if (!accepted) { - try { - await this.rowStore.open(); - stored = await this.rowStore.read(sourceOrder, kinds, ids); - } catch { - // Accepted commits remain readable even if the disk is unavailable. + try { + await this.prepareStore(); + while (this.activeServerIds.has(serverId)) { + await this.flush(); + const revision = this.hostRevisions.get(serverId) ?? 0; + const rows = await this.rowStore.read(serverId, kinds, ids); + if (this.canReadHostRevision(serverId, revision)) return rows; } + return []; + } catch { + return []; } - if (this.hostSources.get(serverId) !== sources) return []; - const accepts = (key: ReplicaRowKey) => - key.serverId === serverId && kinds.includes(key.kind) && (!ids || ids.includes(key.id)); - const rows = new Map(); - const replacingBaseline = this.acceptedBaselines.has(serverId); - // Moved rows replace collisions at the destination, even before the disk rename commits. - stored.sort((a, b) => sourceOrder.indexOf(b.serverId) - sourceOrder.indexOf(a.serverId)); - for (const row of stored) { - if (replacingBaseline && row.kind !== "timeline") continue; - rows.set(rowKey(row), { ...row, serverId }); - } - for (const pending of this.acceptedRows.values()) { - if (!accepts(pending.key)) continue; - if (pending.upsert === null) rows.delete(rowKey(pending.key)); - else rows.set(rowKey(pending.key), pending.upsert); - } - return [...rows.values()]; } - private repairInvalidDirectoryRows( + private async deleteInvalidRow(row: ReplicaRow): Promise { + const invalidEntity = directoryEntityForRow(row); + if (!invalidEntity) { + const changes = { upserts: [], deletes: [row] } satisfies ReplicaRowChanges; + await this.queueOperation(async () => { + await this.rowStore.apply(changes); + this.applyStoredChanges(changes); + }); + return; + } + + const checkpointRow = ( + await this.readRows(row.serverId, ["checkpoint"], [REPLICA_SINGLETON_ROW_ID]) + )[0]; + let checkpoint: DirectoryCheckpoint | undefined; + if (checkpointRow) { + try { + checkpoint = parseStoredPayload(DirectoryCheckpointSchema, checkpointRow.payload); + checkpoint = { ...checkpoint }; + delete checkpoint[invalidEntity]; + } catch { + checkpoint = undefined; + } + } + const changes: ReplicaRowChanges = { + deletes: [row, ...(checkpointRow && !checkpoint ? [checkpointRow] : [])], + upserts: + checkpointRow && checkpoint + ? [ + { + serverId: row.serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + payload: JSON.stringify(checkpoint), + }, + ] + : [], + }; + await this.queueOperation(async () => { + await this.rowStore.apply(changes); + this.applyStoredChanges(changes); + }); + } + + private async repairInvalidDirectoryRows( serverId: string, invalidRows: ReplicaRow[], checkpoint: DirectoryCheckpoint | undefined, - ): void { - for (const row of invalidRows) this.queueDelete(row); + ): Promise { const checkpointWasInvalid = invalidRows.some((row) => row.kind === "checkpoint"); - if (checkpoint !== undefined && !checkpointWasInvalid) { - this.queueUpsert({ - serverId, - kind: "checkpoint", - id: REPLICA_SINGLETON_ROW_ID, - value: checkpoint, - }); - } - this.schedulePersist(); + const changes: ReplicaRowChanges = { + deletes: invalidRows, + upserts: + checkpoint !== undefined && !checkpointWasInvalid + ? [ + { + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + payload: JSON.stringify(checkpoint), + }, + ] + : [], + }; + await this.queueOperation(async () => { + await this.rowStore.apply(changes); + this.applyStoredChanges(changes); + }); } commitDirectoryMutations( @@ -1059,9 +1076,10 @@ export class ReplicaCache { ): void { if (!this.activeServerIds.has(serverId)) return; if (this.invalidatedHosts.has(serverId)) return; + this.advanceHostRevision(serverId); for (const mutation of mutations) { if (mutation.type === "delete") { - this.queueDelete({ serverId, kind: mutation.kind, id: mutation.id }); + this.queueEntityDelete(serverId, mutation.kind, mutation.id); } else { this.queueUpsert({ serverId, @@ -1084,18 +1102,15 @@ export class ReplicaCache { replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void { if (!this.activeServerIds.has(serverId)) return; + this.advanceHostRevision(serverId); // Replacing the directory must not discard independently accepted timeline changes. - for (const [key, pending] of this.pendingRows) { - if (pending.key.serverId === serverId && pending.key.kind !== "timeline") { - this.pendingRows.delete(key); - } + for (const [key, row] of this.pendingUpserts) { + if (row.serverId === serverId && row.kind !== "timeline") this.pendingUpserts.delete(key); } - this.pendingBaselines.set(serverId, Symbol("baseline")); - this.acceptedBaselines.add(serverId); - for (const [key, row] of this.acceptedRows) { - if (row.key.serverId === serverId && row.key.kind !== "timeline") - this.acceptedRows.delete(key); + for (const [key, row] of this.pendingDeletes) { + if (row.serverId === serverId && row.kind !== "timeline") this.pendingDeletes.delete(key); } + this.pendingBaselines.add(serverId); for (const [id, value] of directory.agents) { this.queueUpsert({ serverId, kind: "agent", id, value }); } @@ -1116,23 +1131,10 @@ export class ReplicaCache { this.schedulePersist(); } - removeTimeline(serverId: string, agentId: string): void { - if (!this.activeServerIds.has(serverId)) return; - this.queueDelete({ serverId, kind: "timeline", id: agentId }); - this.schedulePersist(); - } - - commitTimeline( - serverId: string, - agentId: string, - timeline: CachedTimeline, - requireCertified = false, - ): void { + commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void { if (!this.activeServerIds.has(serverId)) return; if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload"); - // The timeline authority can retain a good restart snapshot while its richer accepted - // page cannot be represented with truthful coverage. - if (requireCertified && !isCertifiedTimeline(timeline)) return; + this.advanceHostRevision(serverId); this.queueUpsert({ serverId, kind: "timeline", id: agentId, value: timeline }); this.schedulePersist(); } @@ -1140,193 +1142,148 @@ export class ReplicaCache { setHosts(serverIds: Iterable): void { const next = new Set(serverIds); const removed = [...this.activeServerIds].filter((serverId) => !next.has(serverId)); - this.activeServerIds = next; - for (const serverId of next) { - if (!this.hostSources.has(serverId)) this.hostSources.set(serverId, new Set([serverId])); - } + const added = [...next].filter((serverId) => !this.activeServerIds.has(serverId)); + for (const serverId of [...removed, ...added]) this.advanceHostRevision(serverId); + this.activeServerIds.clear(); + for (const serverId of next) this.activeServerIds.add(serverId); for (const serverId of removed) { - this.dropAcceptedHost(serverId); + this.removeStoredHost(serverId); this.dropPendingHostChanges(serverId); this.invalidatedHosts.delete(serverId); - this.applyToImage(() => this.removeStoredHost(serverId)); - void this.deleteStoredSource(serverId); + this.queueOperation(() => this.rowStore.deleteHost(serverId)); } } reconcileServerId(oldServerId: string, newServerId: string): void { - if (oldServerId === newServerId) return; - this.renamePendingHostChanges(oldServerId, newServerId); - const sources = this.hostSources.get(oldServerId) ?? new Set(); - const retiredSources = new Set(); - this.hostSources.set(oldServerId, retiredSources); - this.hostSources.set(newServerId, new Set([...sources, newServerId])); - if (this.acceptedBaselines.delete(oldServerId)) this.acceptedBaselines.add(newServerId); - for (const [key, row] of this.acceptedRows) { - if (row.key.serverId !== oldServerId) continue; - this.acceptedRows.delete(key); - const renamed = { - key: { ...row.key, serverId: newServerId }, - upsert: row.upsert ? { ...row.upsert, serverId: newServerId } : null, - }; - this.acceptedRows.set(pendingRowKey(renamed.key), renamed); + this.advanceHostRevision(oldServerId); + this.advanceHostRevision(newServerId); + const rows = this.storedRows.get(oldServerId); + if (rows) { + const newRows = this.storedRows.get(newServerId) ?? new Map(); + this.totalBytes -= + (this.hostBytes.get(oldServerId) ?? 0) + (this.hostBytes.get(newServerId) ?? 0); + this.storedRows.delete(oldServerId); + for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId }); + this.storedRows.set(newServerId, newRows); + const bytes = [...newRows.values()].reduce((sum, row) => sum + rowBytes(row), 0); + this.hostBytes.delete(oldServerId); + this.hostBytes.set(newServerId, bytes); + this.totalBytes += bytes; + this.hostWriteOrder.delete(oldServerId); + this.touchHost(newServerId); } + this.renamePendingHostChanges(oldServerId, newServerId); if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId); - this.renamingHosts.set(oldServerId, newServerId); - this.applyToImage(() => { - this.renamingHosts.delete(oldServerId); - this.renameStoredHost(oldServerId, newServerId); - }); - void this.queueDisk(async () => { - await this.rowStore.renameHost(oldServerId, newServerId); - for (const aliases of this.hostSources.values()) aliases.delete(oldServerId); - if (this.hostSources.get(oldServerId) === retiredSources) retiredSources.add(oldServerId); - }); + this.queueOperation(() => this.rowStore.renameHost(oldServerId, newServerId)); } async flush(): Promise { await this.persist(); - await this.disk; + await this.writeQueue.catch(() => undefined); } - private ready(): Promise { - this.image ??= this.queueDisk(() => this.loadStoredRows()).then( - () => true, - () => { - this.image = null; - return false; - }, - ); - return this.image; + private async flushPending(): Promise { + await this.persist(); } - private async loadStoredRows(): Promise { - // COMPAT(replica-blob-cache): remove after 2026-11 - await this.clearLegacyCache().catch(() => undefined); - const hosts = await this.rowStore.readAll(); - for (const host of hosts) { - if (!this.activeServerIds.has(host.serverId) && !this.renamingHosts.has(host.serverId)) { - await this.rowStore.deleteHost(host.serverId); - } - } - // Publish only after all fallible work succeeds; retries must not count partial loads twice. - for (const host of hosts) { - if (!this.activeServerIds.has(host.serverId) && !this.renamingHosts.has(host.serverId)) { - continue; - } - const rows = new Map(host.rows.map((row) => [rowKey(row), row])); - const bytes = host.rows.reduce((sum, row) => sum + rowBytes(row), 0); - this.storedRows.set(host.serverId, rows); - this.hostBytes.set(host.serverId, bytes); - this.totalBytes += bytes; - this.touchHost(host.serverId); - } - } - - // Keep budget accounting in order with its initial load and host identity changes. - private applyToImage(mutation: () => void): void { - if (this.image === null) { - mutation(); - return; - } - this.image = this.image.then((loaded) => { - // Identity changes still settle when loading fails; the next attempt reads their disk result. - mutation(); - return loaded; - }); - } - - private queueDisk(operation: () => Promise): Promise { - const run = this.disk.then(async () => { - await this.rowStore.open(); - await operation(); - return undefined; - }); - this.disk = run.catch(() => undefined); - return run; - } - - private persist(): Promise { + private async persist(): Promise { if (this.persistTimer) { clearTimeout(this.persistTimer); this.persistTimer = null; } - const write = this.writes.then(() => this.writePendingChanges()); - this.writes = write.catch(() => undefined); - return write; - } - - private async writePendingChanges(): Promise { if (!this.hasPendingChanges()) return; - if (!(await this.ready())) { - this.schedulePersist(); - return; - } - const batch: PendingBatch = { - rows: new Map(this.pendingRows), - baselines: new Map(this.pendingBaselines), - }; - const changes = this.materializeBatch(batch); - const { changes: boundedChanges, evicted } = await this.fitChangesToBudget(changes); - if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) { - try { - await this.queueDisk(() => this.rowStore.apply(boundedChanges)); - } catch { - this.schedulePersist(); - return; - } - } - // The write commits to the mirror only what is still pending unchanged. A host removed or - // renamed meanwhile already dropped or re-keyed its pending rows, and its disk delete or - // rename is queued behind this write, so the rows just written must not reappear in memory. - // A row replaced meanwhile stays pending and is written again. - const settled = (key: ReplicaRowKey) => { - const pending = batch.rows.get(pendingRowKey(key)); - return pending !== undefined && this.pendingRows.get(pendingRowKey(key)) === pending; - }; - const baselineSettled = (serverId: string) => - this.pendingBaselines.get(serverId) === batch.baselines.get(serverId); - this.applyStoredChanges({ - upserts: boundedChanges.upserts.filter(settled), - deletes: boundedChanges.deletes.filter( - (key) => - settled(key) || (!batch.rows.has(pendingRowKey(key)) && baselineSettled(key.serverId)), - ), - }); - for (const [key, pending] of batch.rows) { - if (this.pendingRows.get(key) === pending) this.pendingRows.delete(key); - } - for (const [serverId, token] of batch.baselines) { - if (this.pendingBaselines.get(serverId) !== token) continue; - this.pendingBaselines.delete(serverId); - if (!evicted.has(serverId)) this.invalidatedHosts.delete(serverId); - } + const write = this.writeQueue + .catch(() => undefined) + .then(async () => { + const pending = this.drainPendingChanges(); + try { + await this.prepareStore(); + await this.ensureStoredIndex(); + const changes = this.materializePendingChanges(pending); + const { changes: boundedChanges, evicted } = await this.fitChangesToBudget(changes); + if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) { + await this.rowStore.apply(boundedChanges); + this.applyStoredChanges(boundedChanges); + } + for (const serverId of pending.baselines) { + if (!evicted.has(serverId)) this.invalidatedHosts.delete(serverId); + } + } catch { + this.restorePendingChanges(pending); + if (this.hasPendingChanges()) this.schedulePersist(); + } + return undefined; + }); + this.writeQueue = write; + await write; } - private queueUpsert(upsert: StructuredReplicaUpsert): void { - const row = { key: upsert, upsert }; - this.pendingRows.set(pendingRowKey(upsert), row); - this.acceptedRows.set(pendingRowKey(upsert), row); + private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void { + this.queueDelete({ serverId, kind, id }); + } + + private queueUpsert(row: StructuredReplicaUpsert): void { + const key = pendingRowKey(row); + this.pendingDeletes.delete(key); + this.pendingUpserts.set(key, row); } private queueDelete(key: ReplicaRowKey): void { - const { serverId, kind, id } = key; - const row = { key: { serverId, kind, id }, upsert: null }; - this.pendingRows.set(pendingRowKey(key), row); - this.acceptedRows.set(pendingRowKey(key), row); + const pendingKey = pendingRowKey(key); + this.pendingUpserts.delete(pendingKey); + this.pendingDeletes.set(pendingKey, key); } private hasPendingChanges(): boolean { - return this.pendingRows.size > 0 || this.pendingBaselines.size > 0; + return ( + this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0 || this.pendingBaselines.size > 0 + ); } - private materializeBatch(batch: PendingBatch): ReplicaRowChanges { - const deletes = new Map(); - const upserts: ReplicaRow[] = []; - for (const pending of batch.rows.values()) { - if (pending.upsert === null) deletes.set(pendingRowKey(pending.key), pending.key); - else upserts.push(materializeUpsert(pending.upsert)); + private canReadHostRevision(serverId: string, revision: number): boolean { + return ( + this.activeServerIds.has(serverId) && + (this.hostRevisions.get(serverId) ?? 0) === revision && + !this.hasPendingHostChanges(serverId) + ); + } + + private hasPendingHostChanges(serverId: string): boolean { + if (this.pendingBaselines.has(serverId)) return true; + for (const row of this.pendingUpserts.values()) { + if (row.serverId === serverId) return true; } - for (const serverId of batch.baselines.keys()) { + for (const row of this.pendingDeletes.values()) { + if (row.serverId === serverId) return true; + } + return false; + } + + private advanceHostRevision(serverId: string): void { + this.hostRevisions.set(serverId, (this.hostRevisions.get(serverId) ?? 0) + 1); + } + + private drainPendingChanges(): PendingReplicaChanges { + const changes = { + upserts: [...this.pendingUpserts.values()], + deletes: [...this.pendingDeletes.values()], + baselines: this.pendingBaselines, + }; + this.pendingUpserts = new Map(); + this.pendingDeletes = new Map(); + this.pendingBaselines = new Set(); + return changes; + } + + private materializePendingChanges(pending: PendingReplicaChanges): ReplicaRowChanges { + const deletes = new Map(pending.deletes.map((row) => [pendingRowKey(row), row])); + const upserts: ReplicaRow[] = []; + for (const upsert of pending.upserts) { + const row = this.materializeUpsert(upsert); + if (row) upserts.push(row); + else deletes.set(pendingRowKey(upsert), upsert); + } + for (const serverId of pending.baselines) { const replacementKeys = new Set( upserts.filter((row) => row.serverId === serverId && row.kind !== "timeline").map(rowKey), ); @@ -1339,6 +1296,60 @@ export class ReplicaCache { return { upserts, deletes: [...deletes.values()] }; } + private materializeUpsert(upsert: StructuredReplicaUpsert): ReplicaRow | null { + let value: unknown; + switch (upsert.kind) { + case "agent": + value = serializeAgent(upsert.value); + break; + case "workspace": + value = serializeWorkspace(upsert.value); + break; + case "project": + value = serializeProject(upsert.value); + break; + case "timeline": + value = serializeTimeline(upsert.value); + if (!value) return null; + break; + case "checkpoint": + value = upsert.value; + break; + } + return { + serverId: upsert.serverId, + kind: upsert.kind, + id: upsert.id, + payload: JSON.stringify(value), + }; + } + + private restorePendingChanges(changes: PendingReplicaChanges): void { + for (const serverId of changes.baselines) { + if (this.activeServerIds.has(serverId)) this.pendingBaselines.add(serverId); + } + for (const key of changes.deletes) { + const pendingKey = pendingRowKey(key); + if ( + this.activeServerIds.has(key.serverId) && + !this.pendingUpserts.has(pendingKey) && + !this.pendingDeletes.has(pendingKey) + ) { + this.queueDelete(key); + } + } + for (const row of changes.upserts) { + const pendingKey = pendingRowKey(row); + if ( + this.activeServerIds.has(row.serverId) && + !this.pendingUpserts.has(pendingKey) && + !this.pendingDeletes.has(pendingKey) + ) { + this.queueUpsert(row); + } + } + } + private applyStoredChanges(changes: ReplicaRowChanges): void { const touchedServerIds = new Set(); for (const key of changes.deletes) { @@ -1412,13 +1423,12 @@ export class ReplicaCache { while (projectedTotal > this.maxBytes) { const serverId = writeOrder.shift(); if (serverId === undefined) break; - projectedTotal -= projectedBytes.get(serverId) ?? 0; + const bytes = projectedBytes.get(serverId) ?? 0; + projectedTotal -= bytes; projectedBytes.delete(serverId); evicted.add(serverId); this.invalidatedHosts.add(serverId); - this.dropPendingHostChanges(serverId); - this.dropAcceptedHost(serverId); - await this.deleteStoredSource(serverId).catch(() => undefined); + if (this.storedRows.has(serverId)) await this.rowStore.deleteHost(serverId); this.removeStoredHost(serverId); } @@ -1431,22 +1441,6 @@ export class ReplicaCache { }; } - private dropAcceptedHost(serverId: string): void { - this.hostSources.set(serverId, new Set()); - this.acceptedBaselines.delete(serverId); - for (const [key, row] of this.acceptedRows) { - if (row.key.serverId === serverId) this.acceptedRows.delete(key); - } - } - - private deleteStoredSource(serverId: string): Promise { - const sources = this.hostSources.get(serverId); - return this.queueDisk(async () => { - await this.rowStore.deleteHost(serverId); - if (this.hostSources.get(serverId) === sources) sources?.add(serverId); - }); - } - private removeStoredHost(serverId: string): void { this.totalBytes -= this.hostBytes.get(serverId) ?? 0; this.storedRows.delete(serverId); @@ -1454,74 +1448,79 @@ export class ReplicaCache { this.hostWriteOrder.delete(serverId); } - private renameStoredHost(oldServerId: string, newServerId: string): void { - const rows = this.storedRows.get(oldServerId); - if (!rows) return; - const newRows = this.storedRows.get(newServerId) ?? new Map(); - this.removeStoredHost(oldServerId); - this.removeStoredHost(newServerId); - for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId }); - this.storedRows.set(newServerId, newRows); - const bytes = [...newRows.values()].reduce((sum, row) => sum + rowBytes(row), 0); - this.hostBytes.set(newServerId, bytes); - this.totalBytes += bytes; - this.touchHost(newServerId); - } - private dropPendingHostChanges(serverId: string): void { this.pendingBaselines.delete(serverId); - for (const [key, pending] of this.pendingRows) { - if (pending.key.serverId === serverId) this.pendingRows.delete(key); + for (const [key, row] of this.pendingUpserts) { + if (row.serverId === serverId) this.pendingUpserts.delete(key); + } + for (const [key, row] of this.pendingDeletes) { + if (row.serverId === serverId) this.pendingDeletes.delete(key); } } private renamePendingHostChanges(oldServerId: string, newServerId: string): void { - const renamed = [...this.pendingRows.values()].filter( - (pending) => pending.key.serverId === oldServerId, - ); - for (const pending of renamed) { - this.pendingRows.delete(pendingRowKey(pending.key)); - const key = { ...pending.key, serverId: newServerId }; - this.pendingRows.set(pendingRowKey(key), { - key, - upsert: pending.upsert ? { ...pending.upsert, serverId: newServerId } : null, - }); + const changes = this.drainPendingChanges(); + for (const key of changes.deletes) { + this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key); } - const baseline = this.pendingBaselines.get(oldServerId); - if (baseline) { - this.pendingBaselines.delete(oldServerId); - this.pendingBaselines.set(newServerId, baseline); + for (const row of changes.upserts) { + this.queueUpsert(row.serverId === oldServerId ? { ...row, serverId: newServerId } : row); + } + for (const serverId of changes.baselines) { + this.pendingBaselines.add(serverId === oldServerId ? newServerId : serverId); } if (this.invalidatedHosts.delete(oldServerId)) this.invalidatedHosts.add(newServerId); } + private prepareStore(): Promise { + this.preparePromise ??= (async () => { + await this.rowStore.open(); + // COMPAT(replica-blob-cache): remove after 2026-11 + await this.clearLegacyCache().catch(() => undefined); + })(); + return this.preparePromise; + } + + private ensureStoredIndex(): Promise { + this.storedIndexPromise ??= this.rowStore.readAll().then(async (hosts) => { + this.storedRows.clear(); + this.hostBytes.clear(); + this.hostWriteOrder.clear(); + this.totalBytes = 0; + for (const host of hosts) { + if (!this.activeServerIds.has(host.serverId)) { + await this.rowStore.deleteHost(host.serverId); + continue; + } + const rows = new Map(host.rows.map((row) => [rowKey(row), row])); + const bytes = host.rows.reduce((sum, row) => sum + rowBytes(row), 0); + this.storedRows.set(host.serverId, rows); + this.hostBytes.set(host.serverId, bytes); + this.totalBytes += bytes; + this.touchHost(host.serverId); + } + return undefined; + }); + return this.storedIndexPromise; + } + + private queueOperation(operation: () => Promise): Promise { + this.writeQueue = this.writeQueue + .catch(() => undefined) + .then(async () => { + await this.prepareStore(); + await operation(); + return undefined; + }) + .catch(() => undefined); + return this.writeQueue; + } + private schedulePersist(): void { if (this.persistTimer) return; this.persistTimer = setTimeout(() => { this.persistTimer = null; - void this.persist(); + void this.flushPending(); }, PERSIST_DELAY_MS); } } - -function materializeUpsert(upsert: StructuredReplicaUpsert): ReplicaRow { - let payload: string; - switch (upsert.kind) { - case "agent": - payload = JSON.stringify(serializeAgent(upsert.value)); - break; - case "workspace": - payload = JSON.stringify(serializeWorkspace(upsert.value)); - break; - case "project": - payload = JSON.stringify(serializeProject(upsert.value)); - break; - case "timeline": - payload = serializeTimelinePayload(upsert.value); - break; - case "checkpoint": - payload = JSON.stringify(upsert.value); - break; - } - return { serverId: upsert.serverId, kind: upsert.kind, id: upsert.id, payload }; -} diff --git a/packages/app/src/runtime/replica-cache/row-store-sqlite.ts b/packages/app/src/runtime/replica-cache/row-store-sqlite.ts index e7de78ad28..daa8ee28ef 100644 --- a/packages/app/src/runtime/replica-cache/row-store-sqlite.ts +++ b/packages/app/src/runtime/replica-cache/row-store-sqlite.ts @@ -108,17 +108,20 @@ export function createSqliteReplicaRowStore( } async function read( - serverIds: readonly string[], + serverId: string, kinds: readonly ReplicaRow["kind"][], ids?: readonly string[], ): Promise { - if (!serverIds.length || !kinds.length || ids?.length === 0) return []; - const placeholders = (values: readonly string[]) => values.map(() => "?").join(","); - const rows = await getConnection().all( - `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( + `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, diff --git a/packages/app/src/runtime/replica-cache/row-store.contract.ts b/packages/app/src/runtime/replica-cache/row-store.contract.ts index ba6848264c..2e267e812d 100644 --- a/packages/app/src/runtime/replica-cache/row-store.contract.ts +++ b/packages/app/src/runtime/replica-cache/row-store.contract.ts @@ -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 () => { diff --git a/packages/app/src/runtime/replica-cache/row-store.ts b/packages/app/src/runtime/replica-cache/row-store.ts index 77b023cc18..b9cd02d99c 100644 --- a/packages/app/src/runtime/replica-cache/row-store.ts +++ b/packages/app/src/runtime/replica-cache/row-store.ts @@ -25,7 +25,7 @@ export interface ReplicaHostRows { export interface ReplicaRowStore { open(): Promise; read( - serverIds: readonly string[], + serverId: string, kinds: readonly ReplicaRowKind[], ids?: readonly string[], ): Promise; diff --git a/packages/app/src/runtime/replica-cache/row-store.web.test.ts b/packages/app/src/runtime/replica-cache/row-store.web.test.ts index 1630fb8e01..472143ee34 100644 --- a/packages/app/src/runtime/replica-cache/row-store.web.test.ts +++ b/packages/app/src/runtime/replica-cache/row-store.web.test.ts @@ -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(); }); diff --git a/packages/app/src/runtime/replica-cache/row-store.web.ts b/packages/app/src/runtime/replica-cache/row-store.web.ts index 8b843fc8b6..12f484bd91 100644 --- a/packages/app/src/runtime/replica-cache/row-store.web.ts +++ b/packages/app/src/runtime/replica-cache/row-store.web.ts @@ -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 { + 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( + store.get([serverId, kind, id]), + ); + return row ? [row] : []; + }), + ) + : kinds.map((kind) => + requestResult( + 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 { 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 { - const transaction = getDatabase().transaction(ROWS_STORE, "readonly"); - const completion = transactionComplete(transaction); - const store = transaction.objectStore(ROWS_STORE); - const requests: Promise[] = []; - for (const serverId of serverIds) { - for (const kind of kinds) { - if (ids) { - for (const id of ids) { - requests.push( - requestResult(store.get([serverId, kind, id])).then((row) => - row ? [row] : [], - ), - ); - } - } else { - requests.push( - requestResult( - 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 { await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => { const rows = transaction.objectStore(ROWS_STORE); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 4f599c3d84..a27a1ed418 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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(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(() => { diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 87c50a9155..c53eae4c49 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -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({ diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 579e7451f4..3f1ae6ffcf 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -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 + | ((prev: Map) => Map), + ) => void; + setAgentStreamHead: ( + serverId: string, + state: + | Map + | ((prev: Map) => Map), + ) => 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 + | ((prev: Map) => Map), + ) => void; + setAgentTimelineHasOlder: ( + serverId: string, + state: Map | ((prev: Map) => Map), + ) => void; + setAgentTimelineHasNewer: ( + serverId: string, + state: Map | ((prev: Map) => Map), + ) => void; setAgentTimelineOlderFetchInFlight: ( serverId: string, state: Map | ((prev: Map) => Map), ) => 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()( }, // 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()( }); }, + 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 = (map: Map, value: T | undefined): Map => { - 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()( }); }, - removeAgentTimeline: (serverId, agentId) => { - set((previous) => { - const session = previous.sessions[serverId]; - if (!session) return previous; - const remove = (map: Map): Map => { - 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) => { diff --git a/packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json b/packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json deleted file mode 100644 index 3ffd41ba74..0000000000 --- a/packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json +++ /dev/null @@ -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 -} diff --git a/packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json b/packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json deleted file mode 100644 index e419c1f2c8..0000000000 --- a/packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json +++ /dev/null @@ -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" - ] -} diff --git a/packages/app/src/timeline/fetch-agent-timeline-once.test.ts b/packages/app/src/timeline/fetch-agent-timeline-once.test.ts new file mode 100644 index 0000000000..1ae97e0281 --- /dev/null +++ b/packages/app/src/timeline/fetch-agent-timeline-once.test.ts @@ -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>; + +test("concurrent identical timeline reads share one request", async () => { + let resolvePage: (page: TimelinePage) => void = () => {}; + const page = new Promise((resolve) => { + resolvePage = resolve; + }); + const requests: Array<{ agentId: string; direction: string }> = []; + const client = { + fetchAgentTimeline: async ( + agentId: string, + request: Parameters[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" }]); +}); diff --git a/packages/app/src/timeline/fetch-agent-timeline-once.ts b/packages/app/src/timeline/fetch-agent-timeline-once.ts new file mode 100644 index 0000000000..e0d67b4897 --- /dev/null +++ b/packages/app/src/timeline/fetch-agent-timeline-once.ts @@ -0,0 +1,31 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; + +type TimelineClient = Pick; +type TimelineRequest = Parameters[1]; +type TimelinePage = Awaited>; + +const inFlightByClient = new WeakMap>>(); + +export function fetchAgentTimelineOnce( + client: TimelineClient, + agentId: string, + request: TimelineRequest, +): Promise { + 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; +} diff --git a/packages/app/src/timeline/replica.contract.test.ts b/packages/app/src/timeline/replica.contract.test.ts deleted file mode 100644 index 0abce24fe0..0000000000 --- a/packages/app/src/timeline/replica.contract.test.ts +++ /dev/null @@ -1,2171 +0,0 @@ -import { - createInitDeferred, - getInitDeferred, - getInitKey, - resolveInitDeferred, -} from "@/utils/agent-initialization"; -import mixedMessage from "./__fixtures__/mixed-unpositioned-message.json"; -import legacyTimeline from "./__fixtures__/legacy-unverifiable-timeline.json"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { ReplicaCache, type CachedTimeline } from "@/runtime/replica-cache"; -import type { - ReplicaHostRows, - ReplicaRow, - ReplicaRowChanges, - ReplicaRowStore, -} from "@/runtime/replica-cache/row-store"; -import { loadOlderAgentHistory } from "@/hooks/use-load-older-agent-history"; -import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; -import { createUserMessage, type StreamItem } from "@/types/stream"; -import { - createTimelineReplica, - type TimelineResponsePayload, - type TimelineReplicaStorage, -} from "./replica"; -import { createViewedTimelineOwner } from "./viewed-timeline-sync"; - -const host = "transaction-host"; -const otherHost = "transaction-other"; -const agentId = "agent"; -const timestamp = "2026-09-06T10:00:00.000Z"; - -class MemoryRows implements ReplicaRowStore { - rows = new Map(); - async open() {} - async read( - serverIds: readonly string[], - kinds: readonly ReplicaRow["kind"][], - ids?: readonly string[], - ): Promise { - return [...this.rows.values()].filter( - (row) => - serverIds.includes(row.serverId) && - kinds.includes(row.kind) && - (!ids || ids.includes(row.id)), - ); - } - - async readAll(): Promise { - return [host, otherHost].map((serverId) => ({ - serverId, - rows: [...this.rows.values()].filter((row) => row.serverId === serverId), - })); - } - async apply(changes: ReplicaRowChanges) { - for (const row of changes.deletes) this.rows.delete(`${row.serverId}:${row.kind}:${row.id}`); - for (const row of changes.upserts) this.rows.set(`${row.serverId}:${row.kind}:${row.id}`, row); - } - async deleteHost(serverId: string) { - for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key); - } - async renameHost(oldId: string, newId: string) { - const rows = [...this.rows.values()].filter((row) => row.serverId === oldId); - await this.deleteHost(oldId); - await this.apply({ - deletes: [], - upserts: rows.map((row) => ({ - kind: row.kind, - id: row.id, - payload: row.payload, - serverId: newId, - })), - }); - } - async clear() { - this.rows.clear(); - } -} - -function cache(rows: MemoryRows): ReplicaCache { - const result = new ReplicaCache(rows, { clearLegacyCache: async () => undefined }); - result.setHosts([host, otherHost]); - return result; -} - -function saved(): CachedTimeline { - return { - agentId, - hasOlder: false, - range: { epoch: "epoch-1", startSeq: 1, endSeq: 80 }, - items: Array.from( - { length: 80 }, - (_, i): StreamItem => ({ - kind: "assistant_message", - id: `message-${i + 1}`, - messageId: `message-${i + 1}`, - text: `row ${i + 1}`, - timestamp: new Date(timestamp), - timelineCursor: { epoch: "epoch-1", seq: i + 1 }, - source: { startSeq: i + 1, chunks: [{ seq: i + 1, offset: 0 }] }, - }), - ), - }; -} - -function page( - start: number, - end: number, - hasNewer = false, - epoch = "epoch-1", -): TimelineResponsePayload { - return { - requestId: `page-${epoch}-${end}`, - agentId, - agent: null, - direction: "after", - projection: "projected", - reset: false, - epoch, - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: end, nextSeq: end + 1 }, - startCursor: { epoch, seq: start }, - endCursor: { epoch, seq: end }, - hasOlder: true, - hasNewer, - error: null, - entries: Array.from({ length: end - start + 1 }, (_, i) => ({ - provider: "claude" as const, - item: { - type: "assistant_message" as const, - text: `row ${start + i}`, - messageId: `message-${start + i}`, - }, - timestamp, - seqStart: start + i, - seqEnd: start + i, - sourceSeqRanges: [{ startSeq: start + i, endSeq: start + i }], - collapsed: [], - })), - }; -} - -function owner(storage: TimelineReplicaStorage, serverId = host) { - const replica = createTimelineReplica({ serverId, storage }); - const viewed = createViewedTimelineOwner({ - serverId, - replica, - replaceDemandedAgentIds: () => undefined, - drainQueuedAgentMessage: () => undefined, - ports: { - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, - fetchPage: async () => ({ hasNewer: false, endCursor: null }), - fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), - reportError: (error) => { - throw error; - }, - schedule: () => () => undefined, - }, - }); - return { replica, viewed }; -} - -function live(viewed: ReturnType["viewed"], seq: number) { - viewed.enqueueStreamEvent(agentId, { - seq, - epoch: "epoch-1", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text: `row ${seq}`, messageId: `message-${seq}` }, - }, - }); -} - -afterEach(() => { - useSessionStore.getState().clearSession(host); - useSessionStore.getState().clearSession(otherHost); -}); - -describe("timeline transaction restart contract", () => { - it.each([false, true])( - "reconciles recorded markdown through display-only reopen (newer live=%s)", - async (newer) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.beginTimelineRequest(agentId, true)({ ...page(10219, 10219), direction: "tail" }); - for (const text of [...mixedMessage.chunks, ...(newer ? ["Newer text"] : [])]) { - viewed.enqueueStreamEvent(agentId, { - timestamp: new Date(timestamp), - seq: undefined, - epoch: undefined, - event: { - type: "timeline", - provider: "codex", - item: { - type: "assistant_message", - messageId: mixedMessage.entry.item.messageId, - text, - }, - }, - }); - viewed.flushStreamAgent(agentId); - } - const displayed = () => { - const session = useSessionStore.getState().sessions[host]!; - return [ - ...(session.agentStreamTail.get(agentId) ?? []), - ...(session.agentStreamHead.get(agentId) ?? []), - ]; - }; - const messageRows = (items: StreamItem[]) => - items.filter( - (row) => - row.kind === "assistant_message" && row.messageId === mixedMessage.entry.item.messageId, - ); - expect(messageRows(displayed())).toHaveLength(2); - const expectedTexts = newer - ? messageRows(displayed()).map((row) => ("text" in row ? row.text : "")) - : [mixedMessage.entry.item.text]; - if (newer) expect(expectedTexts.join("\n\n")).toContain("session.\nNewer text"); - const tool = { - type: "tool_call" as const, - name: "shell", - callId: "following-tool", - status: "running" as const, - error: null, - detail: { type: "shell" as const, command: "pwd" }, - }; - // The real tool follows the split message; positions resume on this connection. - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { type: "timeline", provider: "codex", item: tool }, - }); - viewed.flushStreamAgent(agentId); - viewed.applyTimelineResponse({ - ...page(10220, 10262), - entries: [ - { - ...mixedMessage.entry, - provider: "codex", - item: { ...mixedMessage.entry.item, type: "assistant_message" }, - collapsed: ["assistant_merge"], - }, - { - provider: "codex", - item: tool, - timestamp, - seqStart: 10244, - seqEnd: 10262, - sourceSeqRanges: [ - { startSeq: 10244, endSeq: 10244 }, - { startSeq: 10262, endSeq: 10262 }, - ], - collapsed: ["tool_lifecycle"], - }, - ], - }); - expect(messageRows(displayed()).map((row) => ("text" in row ? row.text : ""))).toEqual( - expectedTexts, - ); - expect( - displayed() - .slice(-2) - .map((row) => row.kind), - ).toEqual(["assistant_message", "tool_call"]); - await disk.flush(); - const certified = await cache(rows).readTimeline(host, agentId); - expect(messageRows(certified!.items)).toHaveLength(1); - expect(certified?.range).toEqual({ epoch: "epoch-1", startSeq: 10219, endSeq: 10262 }); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 10262 }); - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "codex", - item: { type: "assistant_message", messageId: "later", text: "later update" }, - }, - }); - viewed.flushStreamAgent(agentId); - await disk.flush(); - const reopened = await cache(rows).readTimeline(host, agentId); - expect(reopened?.range).toBeNull(); - expect(messageRows(reopened!.items).map((row) => ("text" in row ? row.text : ""))).toEqual( - expectedTexts, - ); - viewed.dispose(); - }, - ); - - it("retains positioned gap detection after an out-of-band running setup tool", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.beginTimelineRequest(agentId, true)({ ...page(41, 80), direction: "tail" }); - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "tool_call", - name: "paseo_worktree_setup", - callId: "setup", - status: "running", - error: null, - detail: { - type: "worktree_setup", - worktreePath: "/repo", - branchName: "branch", - log: "Installing", - commands: [], - }, - }, - }, - }); - viewed.flushStreamAgent(agentId); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toBeNull(); - // The saved display page itself grants no source coverage on a fresh owner. - const fresh = createTimelineReplica({ serverId: host, storage: cache(rows) }); - await fresh.prepare(agentId); - expect(fresh.readCursor(agentId)).toBeUndefined(); - fresh.dispose(); - const gaps: number[] = []; - replica.applyEvents( - agentId, - [ - { - seq: 82, - epoch: "epoch-1", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text: "row 82", messageId: "message-82" }, - }, - }, - ], - (_id, cursor, observed) => { - expect(cursor.endSeq).toBe(80); - gaps.push(observed!); - }, - ); - expect(gaps).toEqual([82]); - viewed.beginTimelineRequest(agentId, true)({ ...page(43, 82), direction: "tail" }); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-1", - startSeq: 43, - endSeq: 82, - }); - viewed.dispose(); - }); - - it.each(["live-first", "tail-first"] as const)( - "converges a cold overflow without another live row: %s", - async (ordering) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const replica = createTimelineReplica({ serverId: host, storage: disk }); - const requests: number[] = []; - const tailStarted = Promise.withResolvers(); - const releaseTail = Promise.withResolvers(); - const viewed = createViewedTimelineOwner({ - serverId: host, - replica, - replaceDemandedAgentIds: () => undefined, - drainQueuedAgentMessage: () => undefined, - ports: { - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, - fetchPage: async (id, request) => { - const seq = request.direction === "after" ? request.cursor.seq : 0; - requests.push(seq); - const response = - seq === 80 - ? { ...page(81, 120, true), window: { minSeq: 1, maxSeq: 199, nextSeq: 200 } } - : page(200, 200); - viewed.beginTimelineRequest(id)(response); - return response; - }, - fetchLatestTail: async (id) => { - const apply = viewed.beginTimelineRequest(id, true); - tailStarted.resolve(); - await releaseTail.promise; - const response = { ...page(160, 199), direction: "tail" as const }; - apply(response); - return response; - }, - reportError: (error) => { - throw error; - }, - schedule: () => () => undefined, - }, - }); - viewed.registerVisibleAgentIds("screen", [agentId]); - viewed.setConnected(true); - await tailStarted.promise; - if (ordering === "live-first") { - live(viewed, 200); - viewed.flushStreamAgent(agentId); - } - releaseTail.resolve(); - await vi.waitFor(() => - expect(replica.readCursor(agentId)?.endSeq).toBeGreaterThanOrEqual(199), - ); - if (ordering === "tail-first") { - live(viewed, 200); - viewed.flushStreamAgent(agentId); - } - await vi.waitFor(() => expect(replica.readCursor(agentId)?.endSeq).toBe(200)); - await disk.flush(); - const restored = await cache(rows).readTimeline(host, agentId); - expect(restored?.items.some((row) => row.timelineCursor?.seq === 200)).toBe(true); - expect(restored?.range?.endSeq).toBe(200); - expect(requests).toEqual(ordering === "live-first" ? [80, 199] : [80]); - // An intentionally requested older window remains detached after synchronization. - viewed.beginTimelineRequest( - agentId, - true, - )({ ...page(41, 80, true), direction: "tail", mergeWindow: true }); - const before = useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId); - live(viewed, 201); - viewed.flushStreamAgent(agentId); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toBe(before); - expect(replica.readCursor(agentId)?.endSeq).not.toBe(201); - viewed.dispose(); - }, - ); - - it.each([false, true])( - "persists all-unpositioned delivery across restore (pending=%s)", - async (pending) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const read = Promise.withResolvers(); - const { replica, viewed } = owner({ - readTimeline: async (...args) => { - await read.promise; - return disk.readTimeline(...args); - }, - commitTimeline: (...args) => disk.commitTimeline(...args), - removeTimeline: (...args) => disk.removeTimeline(...args), - }); - const preparation = replica.prepare(agentId); - if (!pending) { - read.resolve(); - await preparation; - } - for (let i = 1; i <= 3; i++) { - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - messageId: `legacy-${i}`, - text: `legacy ${i}`, - }, - }, - }); - viewed.flushStreamAgent(agentId); - if (i === 1 && pending) { - read.resolve(); - await preparation; - } - await disk.flush(); - const restored = await cache(rows).readTimeline(host, agentId); - expect(restored?.range).toBeNull(); - expect(restored?.items).toHaveLength(i); - expect(replica.readCursor(agentId)).toBeUndefined(); - } - viewed.dispose(); - }, - ); - - it("keeps accepted source coverage and display-only persistence across a delayed restore", async () => { - useSessionStore.getState().initializeSession(host, null); - const disk = cache(new MemoryRows()); - const read = Promise.withResolvers(); - const { replica, viewed } = owner({ - readTimeline: () => read.promise, - commitTimeline: (...args) => disk.commitTimeline(...args), - removeTimeline: (...args) => disk.removeTimeline(...args), - }); - const preparation = replica.prepare(agentId); - viewed.beginTimelineRequest(agentId, true)({ ...page(1, 3), direction: "tail" }); - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", messageId: "legacy", text: "unsequenced" }, - }, - }); - viewed.flushStreamAgent(agentId); - read.resolve({ - ...saved(), - items: saved().items.slice(0, 3), - range: { epoch: "epoch-1", startSeq: 1, endSeq: 3 }, - }); - await preparation; - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 3 }); - await disk.flush(); - expect((await disk.readTimeline(host, agentId))?.range).toBeNull(); - expect( - (await disk.readTimeline(host, agentId))?.items.some( - (row) => "text" in row && row.text === "unsequenced", - ), - ).toBe(true); - viewed.dispose(); - }); - - it.each(["current", "newer", "flag-only"] as const)( - "settles only the owning initialization after a superseded tail (%s)", - async (lifetime) => { - useSessionStore.getState().initializeSession(host, null); - const disk = cache(new MemoryRows()); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.beginTimelineRequest(agentId, true)({ ...page(1, 3), direction: "tail" }); - const key = getInitKey(host, agentId); - const initial = lifetime === "flag-only" ? undefined : createInitDeferred(key, "tail"); - let settled = false; - initial?.promise.then(() => { - settled = true; - return undefined; - }); - useSessionStore - .getState() - .setInitializingAgents(host, (previous) => new Map(previous).set(agentId, true)); - const accept = viewed.beginTimelineRequest(agentId); - live(viewed, 4); - live(viewed, 5); - viewed.flushStreamAgent(agentId); - // Replacing an initialization already has its own deferred identity. - if (lifetime === "newer") createInitDeferred(key, "tail"); - const current = getInitDeferred(key); - const before = useSessionStore.getState().sessions[host]!; - accept({ ...page(1, 4), direction: "tail" }); - await Promise.resolve(); - const after = useSessionStore.getState().sessions[host]!; - expect(after.agentStreamTail.get(agentId)).toBe(before.agentStreamTail.get(agentId)); - expect(after.agentStreamHead.get(agentId)).toBe(before.agentStreamHead.get(agentId)); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 5 }); - expect(after.initializingAgents.get(agentId)).toBe(lifetime === "newer"); - expect(settled).toBe(lifetime === "current"); - expect(getInitDeferred(key)).toBe(lifetime === "newer" ? current : undefined); - resolveInitDeferred(key); - initial?.resolve(); - viewed.dispose(); - }, - ); - - it("preserves display pagination without certifying unsequenced history", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.beginTimelineRequest(agentId, true)({ ...page(41, 80), direction: "tail" }); - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - text: "unsequenced", - messageId: "legacy", - }, - }, - }); - viewed.flushStreamAgent(agentId); - const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[host], agentId); - expect(timeline.status).toBe("synced"); - if (timeline.status !== "synced") throw new Error("Expected painted history"); - expect(timeline.range).toEqual({ epoch: "epoch-1", startSeq: 41, endSeq: 80 }); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - let requested = false; - expect( - await loadOlderAgentHistory(agentId, { - client: { - fetchAgentTimeline: async (id, request) => { - expect(id).toBe(agentId); - expect(request.cursor).toEqual({ epoch: "epoch-1", seq: 41 }); - requested = true; - viewed.applyTimelineResponse({ ...page(1, 40), direction: "before", hasOlder: false }); - }, - }, - cursor: timeline.range ?? undefined, - hasOlder: timeline.older === "available", - isLoadingOlder: false, - setInFlight: () => undefined, - }), - ).toBe(true); - expect(requested).toBe(true); - expect(useSessionStore.getState().sessions[host]?.agentTimelineCursor.get(agentId)).toEqual({ - epoch: "epoch-1", - startSeq: 1, - endSeq: 80, - }); - live(viewed, 82); - viewed.flushStreamAgent(agentId); - await disk.flush(); - const reopened = await cache(rows).readTimeline(host, agentId); - expect(reopened?.range).toBeNull(); - expect(reopened?.items).toHaveLength(40); - expect(reopened?.items.some((row) => (row.source?.startSeq ?? 41) < 41)).toBe(false); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - viewed.beginTimelineRequest(agentId, true)({ ...page(43, 82), direction: "tail" }); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-1", - startSeq: 43, - endSeq: 82, - }); - viewed.dispose(); - }); - - it.each(["live", "cached-live", "page", "empty"] as const)( - "invalidates old display positions on %s epoch/reset after unpositioned activity", - async (transition) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - if (transition === "cached-live") { - disk.commitTimeline(host, agentId, { - ...saved(), - items: saved().items.slice(40), - range: { epoch: "epoch-1", startSeq: 41, endSeq: 80 }, - hasOlder: true, - }); - await disk.flush(); - } - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - if (transition !== "cached-live") - viewed.beginTimelineRequest(agentId, true)({ ...page(41, 80), direction: "tail" }); - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text: "unsequenced", messageId: "legacy" }, - }, - }); - viewed.flushStreamAgent(agentId); - if (transition === "live" || transition === "cached-live") { - viewed.enqueueStreamEvent(agentId, { - seq: 1, - epoch: "epoch-2", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text: "new epoch", messageId: "new" }, - }, - }); - viewed.flushStreamAgent(agentId); - } else { - const response = page(1, transition === "empty" ? 0 : 1, false, "epoch-2"); - viewed.applyTimelineResponse({ - ...response, - direction: "tail", - reset: true, - hasOlder: false, - ...(transition === "empty" ? { startCursor: null, endCursor: null } : {}), - }); - } - const range = useSessionStore.getState().sessions[host]?.agentTimelineCursor.get(agentId); - expect(range).toEqual( - transition === "page" || transition === "live" - ? { epoch: "epoch-2", startSeq: 1, endSeq: 1 } - : undefined, - ); - expect(useSessionStore.getState().sessions[host]?.agentTimelineHasOlder.get(agentId)).toBe( - false, - ); - await disk.flush(); - const restored = await cache(rows).readTimeline(host, agentId); - if (transition === "cached-live") { - // Until the new epoch is verified, keep the preceding durable baseline. - expect(restored?.items).toHaveLength(40); - expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 41, endSeq: 80 }); - } else { - expect(restored?.items).toHaveLength(transition === "empty" ? 0 : 1); - expect(restored?.range).toEqual( - transition === "empty" ? null : { epoch: "epoch-2", startSeq: 1, endSeq: 1 }, - ); - } - viewed.dispose(); - }, - ); - - it("does not publish a non-timeline event that changes no displayed fact", async () => { - useSessionStore.getState().initializeSession(host, null); - const { replica, viewed } = owner(cache(new MemoryRows())); - await replica.prepare(agentId); - viewed.applyTimelineResponse({ ...page(1, 3), direction: "tail" }); - const before = useSessionStore.getState(); - let notifications = 0; - const unsubscribe = useSessionStore.subscribe(() => notifications++); - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { type: "turn_started", provider: "claude", turnId: "turn" }, - }); - viewed.flushStreamAgent(agentId); - expect(useSessionStore.getState()).toBe(before); - expect(notifications).toBe(0); - live(viewed, 4); - viewed.flushStreamAgent(agentId); - expect(notifications).toBe(1); - expect( - useSessionStore.getState().sessions[host]?.agentTimelineCursor.get(agentId)?.endSeq, - ).toBe(4); - unsubscribe(); - viewed.dispose(); - }); - - it.each([false, true])( - "refreshes display-only restarts for unsequenced activity (same message=%s)", - async (sameMessage) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.beginTimelineRequest(agentId, true)({ ...page(31, 40), direction: "tail" }); - viewed.applyTimelineResponse({ ...page(1, 30), direction: "before", hasOlder: false }); - for (let batch = 1; batch <= 3; batch++) { - viewed.enqueueStreamEvent(agentId, { - seq: undefined, - epoch: undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - text: `legacy ${batch}`, - messageId: sameMessage ? "message-40" : `legacy-${batch}`, - }, - }, - }); - viewed.flushStreamAgent(agentId); - if (batch === 1) - viewed.applyTimelineResponse({ - ...page(1, 30), - direction: "before", - hasOlder: false, - }); - live(viewed, 40 + batch); - viewed.flushStreamAgent(agentId); - await disk.flush(); - const restored = await cache(rows).readTimeline(host, agentId); - expect(restored?.range).toBeNull(); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 40 + batch }); - expect( - restored?.items.some((row) => "text" in row && row.text.includes(`legacy ${batch}`)), - ).toBe(true); - expect( - restored?.items.some((row) => "text" in row && row.text === `row ${40 + batch}`), - ).toBe(true); - expect(restored?.items.some((row) => row.source && row.source.startSeq < 31)).toBe(false); - expect(restored!.items.length).toBeLessThanOrEqual(40); - } - viewed.beginTimelineRequest(agentId, true)({ ...page(31, 43), direction: "tail" }); - await disk.flush(); - const canonical = await cache(rows).readTimeline(host, agentId); - expect(canonical?.range).toEqual({ epoch: "epoch-1", startSeq: 31, endSeq: 43 }); - expect(canonical?.items.map((row) => ("text" in row ? row.text : row.kind))).toEqual( - Array.from({ length: 13 }, (_, i) => `row ${i + 31}`), - ); - // A subsequent positioned gap must keep that certificate through interruption. - live(viewed, 46); - viewed.flushStreamAgent(agentId); - viewed.applyTimelineResponse(page(44, 44, true)); - await disk.flush(); - const partial = await cache(rows).readTimeline(host, agentId); - expect(partial?.range).toEqual({ epoch: "epoch-1", startSeq: 31, endSeq: 44 }); - expect(partial?.items.some((row) => row.timelineCursor?.seq === 46)).toBe(false); - viewed.dispose(); - }, - ); - - it.each(["captured", "renamed", "commit", "deleted", "disposed", "host-removed"] as const)( - "settles a real cache restore across identity transfer: %s", - async (ordering) => { - const rows = new MemoryRows(); - const seed = cache(rows); - seed.commitTimeline(host, agentId, saved()); - await seed.flush(); - const started = Promise.withResolvers(); - const release = Promise.withResolvers(); - const readRows = rows.read.bind(rows); - let reads = 0; - rows.read = async (...args) => { - reads++; - if (reads !== 1) return readRows(...args); - const captured = ordering === "captured" ? await readRows(...args) : undefined; - started.resolve(); - await release.promise; - return captured ?? readRows(...args); - }; - useSessionStore.getState().initializeSession(host, null); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - const preparation = replica.prepare(agentId); - await started.promise; - disk.reconcileServerId(host, otherHost); - useSessionStore.getState().initializeSession(otherHost, null); - replica.reconcileServerId(otherHost); - useSessionStore.getState().clearSession(host); - await disk.flush(); - if (ordering === "commit") { - disk.commitTimeline(otherHost, agentId, { ...saved(), items: saved().items.slice(40) }); - } - if (ordering === "deleted") replica.remove(agentId, "deleted"); - if (ordering === "disposed") replica.dispose(); - if (ordering === "host-removed") disk.setHosts([]); - release.resolve(); - await preparation; - if (ordering === "deleted" || ordering === "disposed" || ordering === "host-removed") { - expect(replica.readCursor(agentId)).toBeUndefined(); - expect( - useSessionStore.getState().sessions[otherHost]?.agentStreamTail.get(agentId) ?? [], - ).toEqual([]); - expect(reads).toBe(1); - viewed.dispose(); - return; - } - expect(replica.prepare(agentId)).toBe(preparation); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - expect(useSessionStore.getState().sessions[otherHost]?.agentStreamTail.get(agentId)).toEqual( - ordering === "commit" ? saved().items.slice(40) : saved().items, - ); - expect(reads).toBe(ordering === "commit" ? 1 : 2); - viewed.dispose(); - }, - ); - - it.each([false, true])( - "transfers live display and submissions with the owner while restore is pending=%s", - async (pending) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - let reads = 0; - const read = Promise.withResolvers(); - const { replica, viewed } = owner({ - readTimeline: () => { - reads++; - return read.promise; - }, - commitTimeline: (...args) => disk.commitTimeline(...args), - removeTimeline: (...args) => disk.removeTimeline(...args), - }); - const preparation = replica.prepare(agentId); - if (!pending) { - read.resolve(await disk.readTimeline(host, agentId)); - await preparation; - } - live(viewed, 81); - viewed.flushStreamAgent(agentId); - const message = createUserMessage({ - text: "pending send", - clientMessageId: "local", - timestamp: new Date(timestamp), - }); - replica.beginSubmission(agentId, message, true); - const before = useSessionStore.getState().sessions[host]!; - viewed.dispose(); - useSessionStore.getState().initializeSession(otherHost, null); - disk.reconcileServerId(host, otherHost); - replica.reconcileServerId(otherHost); - useSessionStore.getState().clearSession(host); - const transferred = useSessionStore.getState().sessions[otherHost]!; - expect(transferred.agentStreamTail.get(agentId)).toBe(before.agentStreamTail.get(agentId)); - expect(transferred.agentStreamHead.get(agentId)).toBe(before.agentStreamHead.get(agentId)); - expect(transferred.messageSubmissions.get(agentId)).toEqual( - before.messageSubmissions.get(agentId), - ); - if (pending) read.resolve(await disk.readTimeline(otherHost, agentId)); - await preparation; - expect(replica.prepare(agentId)).toBe(preparation); - expect(reads).toBe(pending ? 2 : 1); - expect(replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - const restored = useSessionStore.getState().sessions[otherHost]!; - const display = [ - ...restored.agentStreamTail.get(agentId)!, - ...restored.agentStreamHead.get(agentId)!, - ]; - expect( - display.some((row) => row.kind === "assistant_message" && row.messageId === "message-81"), - ).toBe(true); - expect( - display.some((row) => row.kind === "user_message" && row.clientMessageId === "local"), - ).toBe(true); - expect(useSessionStore.getState().sessions[host]).toBeUndefined(); - expect(replica.rejectSubmission(agentId, "local", true)).toBe("rejected"); - replica.dispose(); - }, - ); - - it("paints the real legacy page without resuming its unverified cursor, then replaces it canonically", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const id = legacyTimeline.agentId; - rows.rows.set(`${host}:timeline:${id}`, { - serverId: host, - kind: "timeline", - id, - payload: JSON.stringify(legacyTimeline), - }); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(id); - expect( - useSessionStore - .getState() - .sessions[host]?.agentStreamTail.get(id) - ?.map((row) => row.timelineCursor?.seq), - ).toEqual([34, 69, 1, 34, 35, 69]); - expect(replica.readCursor(id)).toBeUndefined(); - // Live activity cannot grant certification to the old page. - viewed.enqueueStreamEvent(id, { - seq: 70, - epoch: legacyTimeline.range.epoch, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", messageId: "new", text: "new activity" }, - }, - }); - viewed.flushStreamAgent(id); - await disk.flush(); - expect((await cache(rows).readTimeline(host, id))?.range).toBeNull(); - - const requests: string[] = []; - const canonical = { - ...page(1, 70, false, legacyTimeline.range.epoch), - agentId: id, - direction: "tail" as const, - hasOlder: false, - entries: [ - legacyTimeline.items[2], - legacyTimeline.items[0], - legacyTimeline.items[4], - legacyTimeline.items[1], - ].map((row) => ({ - provider: "claude" as const, - timestamp: row.timestamp, - item: { - type: - row.kind === "user_message" - ? ("user_message" as const) - : ("assistant_message" as const), - text: row.text, - messageId: row.messageId, - }, - seqStart: row.timelineCursor.seq, - seqEnd: row.timelineCursor.seq, - sourceSeqRanges: [{ startSeq: row.timelineCursor.seq, endSeq: row.timelineCursor.seq }], - collapsed: [], - })), - }; - canonical.entries.push({ - provider: "claude", - timestamp, - item: { type: "assistant_message", text: "new activity", messageId: "new" }, - seqStart: 70, - seqEnd: 70, - sourceSeqRanges: [{ startSeq: 70, endSeq: 70 }], - collapsed: [], - }); - viewed.attachTransport({ - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, - fetchPage: async (_id, request) => { - requests.push(request.direction); - viewed.beginTimelineRequest(id)(canonical); - return { hasNewer: false, endCursor: { epoch: canonical.epoch, seq: 70 } }; - }, - fetchLatestTail: async () => { - throw new Error("Unexpected extra tail"); - }, - reportError: (error) => { - throw error; - }, - schedule: () => () => undefined, - }); - viewed.registerVisibleAgentIds("workspace", [id]); - viewed.setConnected(true); - await vi.waitFor(() => expect(viewed.getAgentTimelineStatus(id)).toBe("ready")); - expect(requests).toEqual(["tail"]); - await disk.flush(); - const restored = await cache(rows).readTimeline(host, id); - expect(restored?.range).toEqual({ epoch: canonical.epoch, startSeq: 1, endSeq: 70 }); - expect(restored?.items.map((row) => row.timelineCursor?.seq)).toEqual([1, 34, 35, 69, 70]); - expect( - restored?.items.filter((row) => row.kind === "assistant_message").map((row) => row.text), - ).toEqual([legacyTimeline.items[0].text, legacyTimeline.items[1].text, "new activity"]); - viewed.dispose(); - }); - - it.each(["short", "reasoning", "empty"] as const)( - "keeps a short restart page independent of older browsing (%s)", - async (projection) => { - const snapshots: CachedTimeline[][] = []; - for (const browse of [false, true]) { - useSessionStore.getState().clearSession(host); - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const tail = { ...page(31, 70), direction: "tail" as const }; - // Forty projected units hydrate to twenty rows: each three thoughts merge before an answer. - if (projection !== "short") { - tail.entries = tail.entries.map((entry, i) => { - if (i % 4 === 3) return entry; - const item: TimelineResponsePayload["entries"][number]["item"] = - projection === "reasoning" - ? { type: "reasoning", text: `thought ${i}` } - : { type: "assistant_message", text: "", messageId: `empty-${i}` }; - return { ...entry, item }; - }); - } else { - tail.entries = tail.entries.slice(30); - tail.startCursor = { epoch: "epoch-1", seq: 61 }; - } - viewed.beginTimelineRequest(agentId, true)(tail); - await disk.flush(); - const initial = await cache(rows).readTimeline(host, agentId); - expect(initial?.items).toHaveLength(projection === "reasoning" ? 20 : 10); - const olderEnd = tail.startCursor!.seq - 1; - if (browse) - viewed.applyTimelineResponse({ - ...page(1, olderEnd), - direction: "before", - hasOlder: false, - window: tail.window, - }); - await disk.flush(); - expect(await cache(rows).readTimeline(host, agentId)).toEqual(initial); - const results: CachedTimeline[] = []; - for (const end of [71, 75, 115]) { - const start = results.length === 0 ? 71 : results.at(-1)!.range!.endSeq + 1; - for (let seq = start; seq <= end; seq++) live(viewed, seq); - viewed.flushStreamAgent(agentId); - await disk.flush(); - const snapshot = await cache(rows).readTimeline(host, agentId); - expect(snapshot?.range).toMatchObject({ epoch: "epoch-1", endSeq: end }); - expect(snapshot?.items.length).toBe(Math.min(40, initial!.items.length + end - 70)); - expect(snapshot?.hasOlder).toBe(true); - results.push(snapshot!); - } - snapshots.push(results); - viewed.dispose(); - replica.dispose(); - useSessionStore.getState().clearSession(host); - useSessionStore.getState().initializeSession(host, null); - const reopened = owner(cache(rows)); - await reopened.replica.prepare(agentId); - expect(reopened.replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 115 }); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toEqual( - results[2].items, - ); - expect(results[2].range?.startSeq).toBe(76); - reopened.viewed.applyTimelineResponse({ ...page(36, 75), direction: "before" }); - expect( - useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)?.[0], - ).toMatchObject({ - messageId: "message-36", - text: "row 36", - }); - reopened.viewed.dispose(); - } - expect(snapshots[1]).toEqual(snapshots[0]); - }, - ); - - it.each([ - ["distinct", "old", "new", ["row 2", "row 3"], 2], - ["same", "same", "same", ["row 1row 2", "row 3"], 1], - ["idless", undefined, undefined, ["row 1row 2", "row 3"], 1], - ] as const)( - "persists complete %s messages at the retained prepend boundary", - async (_, olderId, newerId, texts, startSeq) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const tail = { ...page(2, 2), direction: "tail" as const }; - tail.entries[0].item = { type: "assistant_message", text: "row 2", messageId: newerId }; - viewed.beginTimelineRequest(agentId, true)(tail); - const older = { - ...page(1, 1), - direction: "before" as const, - hasOlder: false, - window: tail.window, - }; - older.entries[0].item = { type: "assistant_message", text: "row 1", messageId: olderId }; - viewed.applyTimelineResponse(older); - live(viewed, 3); - viewed.flushStreamAgent(agentId); - await disk.flush(); - const persisted = await cache(rows).readTimeline(host, agentId); - expect( - persisted?.items.map((row) => (row.kind === "assistant_message" ? row.text : row.kind)), - ).toEqual(texts); - expect(persisted?.range).toEqual({ epoch: "epoch-1", startSeq, endSeq: 3 }); - expect(persisted?.items[0].source).toEqual( - startSeq === 1 - ? { - startSeq: 1, - chunks: [ - { seq: 1, offset: 0 }, - { seq: 2, offset: 5 }, - ], - } - : { startSeq: 2, chunks: [{ seq: 2, offset: 0 }] }, - ); - viewed.dispose(); - replica.dispose(); - useSessionStore.getState().clearSession(host); - useSessionStore.getState().initializeSession(host, null); - const reopened = owner(cache(rows)); - await reopened.replica.prepare(agentId); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toEqual( - persisted?.items, - ); - reopened.viewed.dispose(); - }, - ); - - it("bounds overlapping tool history to forty complete entries and retrieves earlier tools by source start", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const response: TimelineResponsePayload = { ...page(1, 60), direction: "tail" }; - const tool = (start: number, end: number): TimelineResponsePayload["entries"][number] => ({ - provider: "claude", - item: { - type: "tool_call", - callId: `tool-${start}`, - name: "Bash", - status: "completed", - error: null, - detail: { type: "unknown", input: null, output: null }, - }, - timestamp, - seqStart: start, - seqEnd: end, - sourceSeqRanges: [ - { startSeq: start, endSeq: start }, - { startSeq: end, endSeq: end }, - ], - collapsed: ["tool_lifecycle"], - }); - for (let start = 1; start <= 20; start++) response.entries[start - 1] = tool(start, 81 - start); - response.window = { minSeq: 1, maxSeq: 80, nextSeq: 81 }; - response.endCursor = { epoch: "epoch-1", seq: 80 }; - viewed.applyTimelineResponse(response); - await disk.flush(); - const snapshot = await cache(rows).readTimeline(host, agentId); - expect(snapshot?.range).toEqual({ epoch: "epoch-1", startSeq: 21, endSeq: 80 }); - expect(snapshot?.items).toHaveLength(40); - expect(snapshot?.hasOlder).toBe(true); - // The last accepted event completed an excluded older tool. The retained page still - // resumes after 80, while older pagination uses source start 21, not completion order. - viewed.dispose(); - replica.dispose(); - useSessionStore.getState().clearSession(host); - useSessionStore.getState().initializeSession(host, null); - const reopenedDisk = cache(rows); - const reopened = owner(reopenedDisk); - await reopened.replica.prepare(agentId); - expect(reopened.replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toHaveLength( - 40, - ); - reopened.viewed.applyTimelineResponse({ - ...response, - direction: "before", - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq: 20 }, - entries: response.entries.slice(0, 20), - hasOlder: false, - }); - const restored = useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId); - expect(restored).toHaveLength(60); - expect(restored?.[0]).toMatchObject({ - kind: "tool_call", - source: { startSeq: 1 }, - timelineCursor: { seq: 80 }, - }); - expect((await cache(rows).readTimeline(host, agentId))?.items).toHaveLength(40); - for (let seq = 81; seq <= 120; seq++) live(reopened.viewed, seq); - // A final page establishes sequencing after the restart; the overlaid events reconcile. - reopened.viewed.applyTimelineResponse(page(81, 120)); - reopened.viewed.flushStreamAgent(agentId); - await reopenedDisk.flush(); - const advanced = await cache(rows).readTimeline(host, agentId); - expect(advanced?.range).toEqual({ epoch: "epoch-1", startSeq: 81, endSeq: 120 }); - expect(advanced?.items).toHaveLength(40); - reopened.viewed.dispose(); - }); - - it.each([ - ["tail", true], - ["tail", false], - ["after", true], - ["after", false], - ] as const)( - "shares accepted markdown and following reductions (%s, positioned=%s)", - async (direction, positioned) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - if (direction === "after") - disk.commitTimeline(host, agentId, { - ...saved(), - items: saved().items.slice(0, 1), - range: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - }); - await disk.flush(); - const restore = Promise.withResolvers(); - const { replica, viewed } = owner({ - readTimeline: async (...args) => { - await restore.promise; - return disk.readTimeline(...args); - }, - commitTimeline: (...args) => disk.commitTimeline(...args), - removeTimeline: (...args) => disk.removeTimeline(...args), - }); - const preparation = replica.prepare(agentId); - if (direction === "tail") { - restore.resolve(); - await preparation; - } - for (const [seq, text] of [ - [2, "para one\n\n"], - [3, "para two"], - ] as const) { - viewed.enqueueStreamEvent(agentId, { - seq: positioned ? seq : undefined, - epoch: positioned ? "epoch-1" : undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - messageId: "markdown", - text, - }, - }, - }); - viewed.flushStreamAgent(agentId); - } - restore.resolve(); - await preparation; - viewed.applyTimelineResponse({ - ...page(2, 3), - direction, - entries: [ - { - ...page(2, 2).entries[0]!, - item: { - type: "assistant_message", - messageId: "markdown", - text: "para one\n\npara two", - }, - seqStart: 2, - seqEnd: 3, - sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }], - }, - ], - }); - const display = () => { - const session = useSessionStore.getState().sessions[host]!; - return [ - ...(session.agentStreamTail.get(agentId) ?? []), - ...(session.agentStreamHead.get(agentId) ?? []), - ]; - }; - const accepted = await disk.readTimeline(host, agentId); - expect(accepted?.items).toStrictEqual(display()); - expect(accepted?.items.at(-1)).toBe(display().at(-1)); - expect(display().at(-1)?.source).toEqual({ startSeq: 2, chunks: [{ seq: 3, offset: 0 }] }); - const control = `${agentId}-control`; - await replica.prepare(control); - viewed.applyTimelineResponse({ ...page(1, 3), direction: "tail", agentId: control }); - function sendMeasured(id: string, seq: number): number { - let textReads = 0; - viewed.enqueueStreamEvent(id, { - seq, - epoch: "epoch-1", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - messageId: `message-${seq}`, - get text() { - textReads++; - return `row ${seq}`; - }, - }, - }, - }); - viewed.flushStreamAgent(id); - return textReads; - } - for (let seq = 4; seq <= 8; seq++) { - const singleReductionReads = sendMeasured(control, seq); - expect(singleReductionReads).toBeGreaterThan(0); - expect(sendMeasured(agentId, seq)).toBe(singleReductionReads); - expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display().at(-1)); - } - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.items).toEqual(display()); - viewed.dispose(); - }, - ); - - it.each([true, false])( - "keeps a lagging paragraph separate only until covered (positioned=%s)", - async (positioned) => { - useSessionStore.getState().initializeSession(host, null); - const disk = cache(new MemoryRows()); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - for (const [seq, text] of [ - [1, "one\n\n"], - [2, "two"], - [3, " newer"], - ] as const) { - viewed.enqueueStreamEvent(agentId, { - seq: positioned ? seq : undefined, - epoch: positioned ? "epoch-1" : undefined, - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", messageId: "m", text }, - }, - }); - viewed.flushStreamAgent(agentId); - } - const partial = { - ...page(1, 2, true), - direction: "tail" as const, - entries: [ - { - ...page(1, 1).entries[0]!, - item: { type: "assistant_message" as const, messageId: "m", text: "one\n\ntwo" }, - seqEnd: 2, - sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], - }, - ], - }; - viewed.applyTimelineResponse(partial); - const display = () => { - const session = useSessionStore.getState().sessions[host]!; - return [ - ...(session.agentStreamTail.get(agentId) ?? []), - ...(session.agentStreamHead.get(agentId) ?? []), - ]; - }; - expect(display().some((row) => "text" in row && row.text.includes(" newer"))).toBe(true); - expect((await disk.readTimeline(host, agentId))?.items).not.toEqual(display()); - viewed.applyTimelineResponse({ - ...page(1, 3), - direction: "tail", - entries: [ - { - ...partial.entries[0]!, - item: { type: "assistant_message", messageId: "m", text: "one\n\ntwo newer" }, - seqEnd: 3, - sourceSeqRanges: [{ startSeq: 1, endSeq: 3 }], - }, - ], - }); - expect(display().map((row) => ("text" in row ? row.text : row.kind))).toEqual([ - "one\n\ntwo newer", - ]); - expect((await disk.readTimeline(host, agentId))?.items[0]).toBe(display()[0]); - live(viewed, 4); - viewed.flushStreamAgent(agentId); - expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display().at(-1)); - viewed.dispose(); - }, - ); - - it.each([true, false])( - "shares canonical same-message segments around a tool (positioned=%s)", - async (positioned) => { - useSessionStore.getState().initializeSession(host, null); - const disk = cache(new MemoryRows()); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const tool = { - type: "tool_call" as const, - callId: "between", - name: "Read", - status: "running" as const, - error: null, - detail: { type: "read" as const, filePath: "/repo/file" }, - }; - const entries: TimelineResponsePayload["entries"] = [ - { - ...page(1, 1).entries[0]!, - item: { type: "assistant_message", messageId: "m", text: "one\n\ntwo" }, - seqEnd: 2, - sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], - }, - { ...page(3, 3).entries[0]!, item: tool }, - { - ...page(4, 4).entries[0]!, - item: { type: "assistant_message", messageId: "m", text: "three\n\nfour" }, - seqEnd: 5, - sourceSeqRanges: [{ startSeq: 4, endSeq: 5 }], - }, - ]; - for (const entry of entries) { - viewed.enqueueStreamEvent(agentId, { - seq: positioned ? entry.seqEnd : undefined, - epoch: positioned ? "epoch-1" : undefined, - timestamp: new Date(timestamp), - event: { type: "timeline", provider: "claude", item: entry.item }, - }); - viewed.flushStreamAgent(agentId); - } - viewed.applyTimelineResponse({ ...page(1, 5), direction: "tail", entries }); - const session = useSessionStore.getState().sessions[host]!; - const display = [ - ...(session.agentStreamTail.get(agentId) ?? []), - ...(session.agentStreamHead.get(agentId) ?? []), - ]; - const accepted = (await disk.readTimeline(host, agentId))!.items; - expect(display.map((row) => ("text" in row ? row.text : row.kind))).toEqual([ - "one\n\ntwo", - "tool_call", - "three\n\nfour", - ]); - expect(new Set(display.map((row) => row.id)).size).toBe(3); - expect(accepted).toEqual(display); - for (let i = 0; i < display.length; i++) expect(accepted[i]).toBe(display[i]); - live(viewed, 6); - viewed.flushStreamAgent(agentId); - expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe( - useSessionStore.getState().sessions[host]?.agentStreamHead.get(agentId)?.at(-1), - ); - viewed.dispose(); - }, - ); - - it.each([false, true])( - "shares accepted rows after synchronization and catch-up (overlay=%s)", - async (overlay) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - if (overlay) { - live(viewed, 3); - viewed.flushStreamAgent(agentId); - } - viewed.applyTimelineResponse({ ...page(1, 3), direction: "tail" }); - const display = () => [ - ...(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId) ?? []), - ...(useSessionStore.getState().sessions[host]?.agentStreamHead.get(agentId) ?? []), - ]; - const accepted = await disk.readTimeline(host, agentId); - expect(accepted?.items).toStrictEqual(display()); - expect(accepted?.items.at(-1)).toBe(display().at(-1)); - live(viewed, 4); - viewed.flushStreamAgent(agentId); - expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display().at(-1)); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.items).toEqual(display()); - viewed.dispose(); - }, - ); - - it.each(["accept", "reject", "handoff"] as const)( - "keeps submission %s transitions shared through following batches and restart", - async (operation) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.applyTimelineResponse({ ...page(1, 3), direction: "tail" }); - const message = createUserMessage({ - clientMessageId: "submission", - text: "prompt", - timestamp: new Date(timestamp), - }); - if (operation === "handoff") replica.handoffSubmission(agentId, message); - else replica.beginSubmission(agentId, message, true); - if (operation === "reject") - expect(replica.rejectSubmission(agentId, "submission", true)).toBe("rejected"); - else { - replica.acceptSubmission(agentId, "submission"); - viewed.enqueueStreamEvent(agentId, { - seq: 4, - epoch: "epoch-1", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { - type: "user_message", - text: "prompt", - messageId: "provider-message", - clientMessageId: "submission", - }, - }, - }); - viewed.flushStreamAgent(agentId); - } - const display = () => [ - ...(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId) ?? []), - ...(useSessionStore.getState().sessions[host]?.agentStreamHead.get(agentId) ?? []), - ]; - for (let seq = operation === "reject" ? 4 : 5; seq <= 14; seq++) { - live(viewed, seq); - viewed.flushStreamAgent(agentId); - expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display().at(-1)); - } - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.items).toEqual(display()); - expect((await cache(rows).readTimeline(host, agentId))?.range?.endSeq).toBe(14); - viewed.dispose(); - }, - ); - - it("reunites a submission with verified history after a cache/live divergence", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - live(viewed, 83); - viewed.flushStreamAgent(agentId); - replica.beginSubmission( - agentId, - createUserMessage({ - clientMessageId: "during-gap", - text: "prompt", - timestamp: new Date(timestamp), - }), - true, - ); - replica.acceptSubmission(agentId, "during-gap"); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range?.endSeq).toBe(80); - const response = page(81, 83); - response.entries[1]!.item = { - type: "user_message", - text: "prompt", - messageId: "canonical", - clientMessageId: "during-gap", - }; - viewed.applyTimelineResponse(response); - for (let seq = 84; seq <= 86; seq++) { - live(viewed, seq); - viewed.flushStreamAgent(agentId); - const session = useSessionStore.getState().sessions[host]; - const display = [ - ...(session?.agentStreamTail.get(agentId) ?? []), - ...(session?.agentStreamHead.get(agentId) ?? []), - ]; - expect((await disk.readTimeline(host, agentId))?.items.at(-1)).toBe(display.at(-1)); - } - await disk.flush(); - const restored = await cache(rows).readTimeline(host, agentId); - expect(restored?.range?.endSeq).toBe(86); - expect(restored?.items.filter((item) => item.kind === "user_message")).toHaveLength(1); - viewed.dispose(); - }); - - it("replaces a requested window while preserving an unverified live overlay", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const acceptTail = viewed.beginTimelineRequest(agentId, true); - live(viewed, 83); - viewed.flushStreamAgent(agentId); - const response = { ...page(81, 82), direction: "tail" as const }; - acceptTail(response); - expect(response.reset).toBe(false); - const session = useSessionStore.getState().sessions[host]; - expect( - [ - ...(session?.agentStreamTail.get(agentId) ?? []), - ...(session?.agentStreamHead.get(agentId) ?? []), - ].map((item) => item.id), - ).toEqual(["message-81", "message-82", "message-83"]); - await disk.flush(); - const restored = await cache(rows).readTimeline(host, agentId); - expect(restored?.items.map((item) => item.id)).toEqual(["message-81", "message-82"]); - expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 81, endSeq: 82 }); - viewed.dispose(); - }); - - it("saves a moving latest page even when that page exceeds 512 KiB and older history is loaded", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const latest = { ...page(161, 200), direction: "tail" as const }; - for (const entry of latest.entries) { - entry.item = { - type: "assistant_message", - messageId: `message-${entry.seqStart}`, - text: "x".repeat(20_000), - }; - } - viewed.applyTimelineResponse(latest); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-1", - startSeq: 161, - endSeq: 200, - }); - viewed.applyTimelineResponse({ ...page(1, 160), direction: "before", hasOlder: false }); - live(viewed, 201); - viewed.flushStreamAgent(agentId); - await disk.flush(); - const savedPage = await cache(rows).readTimeline(host, agentId); - expect(savedPage?.items).toHaveLength(40); - expect(savedPage?.items.at(-1)).toMatchObject({ text: "row 201" }); - expect(savedPage?.range).toEqual({ epoch: "epoch-1", startSeq: 162, endSeq: 201 }); - expect(savedPage?.hasOlder).toBe(true); - viewed.dispose(); - replica.dispose(); - useSessionStore.getState().clearSession(host); - useSessionStore.getState().initializeSession(host, null); - const reopenedDisk = cache(rows); - const reopened = owner(reopenedDisk); - await reopened.replica.prepare(agentId); - expect(reopened.replica.readCursor(agentId)).toEqual({ epoch: "epoch-1", endSeq: 201 }); - expect(useSessionStore.getState().sessions[host]?.agentTimelineCursor.get(agentId)).toEqual({ - epoch: "epoch-1", - startSeq: 162, - endSeq: 201, - }); - reopened.viewed.applyTimelineResponse({ ...page(122, 161), direction: "before" }); - await reopenedDisk.flush(); - expect( - useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)?.[0], - ).toMatchObject({ text: "row 122" }); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-1", - startSeq: 162, - endSeq: 201, - }); - reopened.viewed.dispose(); - }); - - it("a live row painted before the resume keeps the resume bounded", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const calls: string[] = []; - const replica = createTimelineReplica({ serverId: host, storage: disk }); - const viewed = createViewedTimelineOwner({ - serverId: host, - replica, - replaceDemandedAgentIds: () => undefined, - drainQueuedAgentMessage: () => undefined, - ports: { - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, - fetchPage: async (_id, request) => { - const from = request.direction === "after" ? request.cursor.seq : 0; - calls.push(`page ${request.direction} ${from}`); - const end = from + 40; - const hasNewer = end < 600; - viewed.applyTimelineResponse({ - ...page(from + 1, end, hasNewer), - window: { minSeq: 1, maxSeq: 600, nextSeq: 601 }, - }); - return { hasNewer, endCursor: { epoch: "epoch-1", seq: end } }; - }, - fetchLatestTail: async () => { - calls.push("latest-tail"); - viewed.applyTimelineResponse({ ...page(561, 600), direction: "tail", reset: true }); - return { hasNewer: false, endCursor: { epoch: "epoch-1", seq: 600 } }; - }, - reportError: (error) => { - throw error; - }, - schedule: () => () => undefined, - }, - }); - try { - viewed.registerVisibleAgentIds("workspace", [agentId]); - await replica.prepare(agentId); - live(viewed, 590); - viewed.flushStreamAgent(agentId); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual(saved().range); - - viewed.setConnected(true); - await vi.waitFor(() => expect(viewed.getAgentTimelineStatus(agentId)).toBe("ready")); - expect(calls).toEqual(["page after 80", "latest-tail"]); - await disk.flush(); - const reopened = await cache(rows).readTimeline(host, agentId); - expect(reopened?.range).toEqual({ epoch: "epoch-1", startSeq: 561, endSeq: 600 }); - expect(reopened?.items.map((item) => ("text" in item ? item.text : null))).toEqual( - Array.from({ length: 40 }, (_, i) => `row ${561 + i}`), - ); - } finally { - viewed.dispose(); - } - }); - - it("invalidates a certified restart on an authoritative empty reset", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - viewed.applyTimelineResponse({ - ...page(1, 0, false, "epoch-2"), - direction: "tail", - reset: true, - startCursor: null, - endCursor: null, - hasOlder: false, - }); - await disk.flush(); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId) ?? []).toEqual( - [], - ); - expect(await cache(rows).readTimeline(host, agentId)).toMatchObject({ - agentId, - items: [], - range: null, - hasOlder: false, - }); - viewed.dispose(); - }); - - it.each(["cache-first", "live-first"])( - "%s keeps new-epoch activity above an old saved baseline", - async (ordering) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const { replica, viewed } = owner({ - readTimeline: async (serverId, id) => { - const result = await disk.readTimeline(serverId, id); - await gate; - return result; - }, - commitTimeline: (...args) => disk.commitTimeline(...args), - removeTimeline: (...args) => disk.removeTimeline(...args), - }); - const preparation = replica.prepare(agentId); - if (ordering === "cache-first") { - release(); - await preparation; - } - viewed.enqueueStreamEvent(agentId, { - seq: 1, - epoch: "epoch-2", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", messageId: "new", text: "new activity" }, - }, - }); - viewed.flushStreamAgent(agentId); - release(); - await preparation; - const session = useSessionStore.getState().sessions[host]; - expect( - [ - ...(session?.agentStreamTail.get(agentId) ?? []), - ...(session?.agentStreamHead.get(agentId) ?? []), - ].map((item) => ("text" in item ? item.text : null)), - ).toEqual(["new activity"]); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual(saved().range); - viewed.applyTimelineResponse({ ...page(1, 1, false, "epoch-2"), reset: true }); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range?.epoch).toBe("epoch-2"); - viewed.dispose(); - }, - ); - - it.each(["live-before-cache", "live-after-cache", "queued-before-page"])( - "%s preserves a certified restart after every catch-up transition", - async (ordering) => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const { replica, viewed } = owner({ - removeTimeline: () => undefined, - readTimeline: async (serverId, id) => { - const snapshot = await disk.readTimeline(serverId, id); - await gate; - return snapshot; - }, - commitTimeline: (serverId, id, value, requireCertified) => - disk.commitTimeline(serverId, id, value, requireCertified), - }); - const preparation = replica.prepare(agentId); - const restartAt = async (endSeq: number) => { - await disk.flush(); - const reopened = await cache(rows).readTimeline(host, agentId); - const startSeq = endSeq === 80 ? 1 : endSeq - 39; - expect(reopened?.range).toEqual({ epoch: "epoch-1", startSeq, endSeq }); - expect(reopened?.items.map((row) => row.timelineCursor?.seq)).toEqual( - Array.from({ length: endSeq - startSeq + 1 }, (_, i) => i + startSeq), - ); - }; - if (ordering === "live-before-cache") { - live(viewed, 83); - viewed.flushStreamAgent(agentId); - await restartAt(80); - } - release(); - await preparation; - await restartAt(80); - if (ordering !== "live-before-cache") live(viewed, 83); - if (ordering === "live-after-cache") viewed.flushStreamAgent(agentId); - await restartAt(80); - // Owner flushes the queued live batch before accepting the page. - viewed.applyTimelineResponse(page(81, 81, true)); - await restartAt(81); - viewed.applyTimelineResponse(page(82, 82)); - await restartAt(82); - viewed.applyTimelineResponse(page(83, 83)); - await restartAt(83); - const session = useSessionStore.getState().sessions[host]; - expect( - [ - ...(session?.agentStreamTail.get(agentId) ?? []), - ...(session?.agentStreamHead.get(agentId) ?? []), - ].map((row) => row.timelineCursor?.seq), - ).toEqual(Array.from({ length: 83 }, (_, i) => i + 1)); - viewed.dispose(); - replica.dispose(); - }, - ); - - it("settles local identity and attachments through the same publication and restart boundary", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const message = createUserMessage({ - clientMessageId: "submission", - text: "local prompt", - timestamp: new Date(timestamp), - attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }], - images: [ - { - id: "image", - mimeType: "image/png", - storageType: "native-file", - storageKey: "/saved/image.png", - createdAt: 1, - }, - ], - }); - replica.beginSubmission(agentId, message, true); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.items).toHaveLength(80); - const canonical = page(81, 81); - canonical.entries[0].item = { - type: "user_message", - text: "canonical prompt", - messageId: "submission", - }; - viewed.applyTimelineResponse(canonical); - expect(replica.rejectSubmission(agentId, "submission", true)).toBe("accepted"); - await disk.flush(); - expect( - useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)?.at(-1), - ).toMatchObject({ - id: message.id, - text: "local prompt", - attachments: message.attachments, - }); - const restored = await cache(rows).readTimeline(host, agentId); - expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 42, endSeq: 81 }); - expect(restored?.items.at(-1)).toMatchObject({ - attachments: message.attachments, - images: message.images, - }); - replica.beginSubmission( - agentId, - createUserMessage({ - clientMessageId: "rejected", - text: "rejected", - timestamp: new Date(timestamp), - }), - true, - ); - expect(replica.rejectSubmission(agentId, "rejected", true)).toBe("rejected"); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.items).toHaveLength(40); - viewed.dispose(); - }); - - it("ends removed work, accepts a fresh lifetime, and isolates the other host", async () => { - useSessionStore.getState().initializeSession(host, null); - useSessionStore.getState().initializeSession(otherHost, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - disk.commitTimeline(otherHost, agentId, saved()); - await disk.flush(); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const removed = owner({ - removeTimeline: (serverId, id) => disk.removeTimeline(serverId, id), - readTimeline: async () => { - await gate; - return saved(); - }, - commitTimeline: (serverId, id, value, requireCertified) => - disk.commitTimeline(serverId, id, value, requireCertified), - }); - const other = owner(disk, otherHost); - const pending = removed.replica.prepare(agentId); - live(removed.viewed, 81); - removed.replica.remove(agentId, "deleted"); - release(); - await pending; - removed.viewed.applyTimelineResponse(page(81, 81)); - removed.viewed.dispose(); - await disk.flush(); - expect(await cache(rows).readTimeline(host, agentId)).toBeUndefined(); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.has(agentId)).toBe(false); - expect(useSessionStore.getState().sessions[host]?.agentStreamHead.has(agentId)).toBe(false); - removed.replica.acceptAgent(agentId); - removed.viewed.applyTimelineResponse({ - ...page(1, 1, false, "epoch-2"), - direction: "tail", - reset: true, - }); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-2", - startSeq: 1, - endSeq: 1, - }); - await other.replica.prepare(agentId); - other.viewed.applyTimelineResponse(page(81, 81)); - await disk.flush(); - expect((await cache(rows).readTimeline(otherHost, agentId))?.range?.endSeq).toBe(81); - other.viewed.dispose(); - }); - - it("replaces an old epoch with a smaller verified epoch while retaining pending local submissions", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - const { replica, viewed } = owner(disk); - await replica.prepare(agentId); - const local = createUserMessage({ - clientMessageId: "pending", - text: "pending", - timestamp: new Date(timestamp), - }); - replica.beginSubmission(agentId, local, true); - viewed.applyTimelineResponse({ - ...page(1, 1, false, "epoch-2"), - direction: "tail", - reset: true, - hasOlder: false, - }); - await disk.flush(); - const reopened = await cache(rows).readTimeline(host, agentId); - expect(reopened?.range).toEqual({ epoch: "epoch-2", startSeq: 1, endSeq: 1 }); - expect(reopened?.items).toHaveLength(1); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.get(agentId)).toContainEqual( - local, - ); - viewed.dispose(); - }); - it("keeps pending submissions across a live epoch reset", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - viewed.applyTimelineResponse({ - ...page(1, 1), - direction: "tail", - reset: true, - hasOlder: false, - }); - const local = createUserMessage({ - clientMessageId: "pending", - text: "pending", - timestamp: new Date(timestamp), - }); - replica.beginSubmission(agentId, local, true); - replica.applyEvents( - agentId, - [ - { - seq: 1, - epoch: "epoch-2", - timestamp: new Date(timestamp), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text: "new epoch", messageId: "new" }, - }, - }, - ], - () => undefined, - ); - const session = useSessionStore.getState().sessions[host]; - expect([ - ...(session?.agentStreamTail.get(agentId) ?? []), - ...(session?.agentStreamHead.get(agentId) ?? []), - ]).toContainEqual(local); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-2", - startSeq: 1, - endSeq: 1, - }); - viewed.dispose(); - }); - - it("a removal during publication wins over the transition's write-behind", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - const { replica, viewed } = owner(disk); - let removed = false; - const stop = useSessionStore.subscribe((state) => { - if (!removed && state.sessions[host]?.agentTimelineCursor.has(agentId)) { - removed = true; - replica.remove(agentId, "deleted"); - } - }); - viewed.applyTimelineResponse({ ...page(1, 1), direction: "tail", reset: true }); - stop(); - await disk.flush(); - expect(await cache(rows).readTimeline(host, agentId)).toBeUndefined(); - expect(useSessionStore.getState().sessions[host]?.agentStreamTail.has(agentId)).toBe(false); - viewed.dispose(); - }); - - it("keeps a certified attachment-bearing replacement above an unread baseline", async () => { - useSessionStore.getState().initializeSession(host, null); - const rows = new MemoryRows(); - const disk = cache(rows); - disk.commitTimeline(host, agentId, saved()); - await disk.flush(); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const { replica, viewed } = owner({ - removeTimeline: (serverId, id) => disk.removeTimeline(serverId, id), - readTimeline: async () => { - await gate; - return saved(); - }, - commitTimeline: (serverId, id, value, requireCertified) => - disk.commitTimeline(serverId, id, value, requireCertified), - }); - const pending = replica.prepare(agentId); - const local = createUserMessage({ - clientMessageId: "attachment", - text: "prompt", - timestamp: new Date(timestamp), - attachments: [{ type: "text", text: "data", mimeType: "text/plain" }], - }); - replica.beginSubmission(agentId, local, true); - const response = { ...page(81, 81), direction: "tail" as const, reset: true }; - response.entries[0].item = { type: "user_message", messageId: "attachment", text: "prompt" }; - viewed.applyTimelineResponse(response); - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.range).toEqual({ - epoch: "epoch-1", - startSeq: 81, - endSeq: 81, - }); - release(); - await pending; - await disk.flush(); - expect((await cache(rows).readTimeline(host, agentId))?.items.at(-1)).toMatchObject({ - attachments: local.attachments, - }); - viewed.dispose(); - }); -}); diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index c43548a385..e01b0f5ee6 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -1,90 +1,35 @@ +import { DatabaseSync } from "node:sqlite"; +import { ReplicaCache } from "@/runtime/replica-cache"; import { - createTimelineReplica, - type TimelineReplicaStorage, - type TimelineResponsePayload, - type TimelineReplica, -} from "./replica"; -import { afterEach, describe, expect, it, vi } from "vitest"; + createSqliteReplicaRowStore, + type ReplicaSqliteConnection, + type SqliteValue, +} from "@/runtime/replica-cache/row-store-sqlite"; +import { afterEach, describe, expect, it } from "vitest"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { CachedTimeline } from "@/runtime/replica-cache"; import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { + createTimelineReplica, createViewedTimelineOwner, - type TimelinePageResult, + type TimelineReplicaStorage, type ViewedTimelineOwner, } from "./viewed-timeline-sync"; -import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan"; const SERVER_ID = "timeline-replica-host"; const AGENT_ID = "agent-1"; -function item(id: string, text: string, seq: number, messageId?: string): StreamItem { +function item(id: string, text: string, seq: number): StreamItem { return { kind: "assistant_message", id, - ...(messageId ? { messageId } : {}), text, timestamp: new Date("2026-08-26T10:00:00.000Z"), timelineCursor: { epoch: "epoch-1", seq }, }; } -function paintLive( - replica: TimelineReplica, - text: string, - seq: number, - messageId = `message-${seq}`, -): void { - replica.applyEvents( - AGENT_ID, - [ - { - seq, - epoch: "epoch-1", - timestamp: new Date("2026-08-26T10:00:00.000Z"), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text, messageId }, - }, - }, - ], - () => undefined, - ); -} - -function pageAt(seq: number): TimelineResponsePayload { - return { - requestId: `network-${seq}`, - agentId: AGENT_ID, - agent: null, - direction: "tail", - projection: "projected", - reset: false, - epoch: "epoch-1", - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: seq, nextSeq: seq + 1 }, - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq }, - entries: [ - { - provider: "claude", - item: { type: "assistant_message", text: "network", messageId: "network" }, - timestamp: "2026-08-26T10:00:00.000Z", - seqStart: 1, - seqEnd: seq, - sourceSeqRanges: [{ startSeq: 1, endSeq: seq }], - collapsed: [], - }, - ], - hasOlder: false, - hasNewer: false, - error: null, - }; -} - function cachedTimeline(): CachedTimeline { return { agentId: AGENT_ID, @@ -98,6 +43,7 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { const replica = createTimelineReplica({ serverId: SERVER_ID, storage, + prepareAgent: async () => undefined, }); return createViewedTimelineOwner({ serverId: SERVER_ID, @@ -107,6 +53,7 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { ports: { initialDeliveryMode: "legacy", setSubscription: async () => undefined, + readCursor: () => undefined, fetchPage: async () => ({ hasNewer: false, endCursor: null }), fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), reportError: () => undefined, @@ -115,379 +62,21 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { }); } +function applySynced(agentId: string, seq: number): void { + useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, agentId, { + items: [item(`network-${agentId}`, "network", seq)], + head: [], + range: { epoch: "epoch-1", startSeq: 1, endSeq: seq }, + older: "available", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); +} + afterEach(() => useSessionStore.getState().clearSession(SERVER_ID)); describe("viewed timeline persistence", () => { - it("keeps verified history when a final page predates displayed live activity", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - let durable = cachedTimeline(); - const owner = createOwner({ - removeTimeline: () => undefined, - readTimeline: async () => durable, - commitTimeline: (_host, _agent, value) => { - durable = value; - }, - }); - owner.registerVisibleAgentIds("test", [AGENT_ID]); - await vi.waitFor(() => - expect( - selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID).status, - ).toBe("painted"), - ); - owner.enqueueStreamEvent(AGENT_ID, { - seq: 7, - epoch: "epoch-1", - timestamp: new Date("2026-09-06T10:00:00.000Z"), - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - text: "future live", - messageId: "future", - }, - }, - }); - owner.flushStreamAgent(AGENT_ID); - expect(durable.range).toEqual(cachedTimeline().range); - owner.applyTimelineResponse({ - requestId: "old-final", - agentId: AGENT_ID, - agent: null, - direction: "after", - projection: "projected", - reset: false, - epoch: "epoch-1", - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: 5, nextSeq: 6 }, - startCursor: { epoch: "epoch-1", seq: 5 }, - endCursor: { epoch: "epoch-1", seq: 5 }, - hasOlder: true, - hasNewer: false, - error: null, - entries: [ - { - provider: "claude", - item: { type: "assistant_message", text: "verified", messageId: "verified" }, - timestamp: "2026-09-06T10:00:00.000Z", - seqStart: 5, - seqEnd: 5, - sourceSeqRanges: [{ startSeq: 5, endSeq: 5 }], - collapsed: [], - }, - ], - }); - expect(durable.range).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 5 }); - expect(durable.items.map((row) => row.timelineCursor?.seq)).toEqual([4, 5]); - owner.dispose(); - }); - - it("recovers the final live response when an in-flight gap page predates turn completion", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - let durable: CachedTimeline | undefined; - const pending: Array<{ - request: ProjectedTimelineForwardFetchPlan; - respond(payload: TimelineResponsePayload): void; - }> = []; - const replica = createTimelineReplica({ - serverId: SERVER_ID, - storage: { - removeTimeline: () => undefined, - readTimeline: async () => undefined, - commitTimeline: (_serverId, _agentId, timeline) => { - durable = timeline; - }, - }, - }); - const owner = createViewedTimelineOwner({ - serverId: SERVER_ID, - replica, - replaceDemandedAgentIds: () => undefined, - drainQueuedAgentMessage: () => undefined, - ports: { - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, - fetchPage: (_agentId, request) => - new Promise((resolve) => { - pending.push({ - request, - respond(payload) { - owner.applyTimelineResponse(payload); - resolve(payload); - }, - }); - }), - fetchLatestTail: async () => { - throw new Error("A contiguous forward gap does not require a reset"); - }, - reportError: (error) => { - throw error; - }, - schedule: () => () => undefined, - }, - }); - const page = (startSeq: number, endSeq: number, text: string): TimelineResponsePayload => ({ - requestId: `page-${endSeq}`, - agentId: AGENT_ID, - agent: null, - direction: "after", - projection: "projected", - epoch: "epoch-1", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: endSeq, nextSeq: endSeq + 1 }, - startCursor: { epoch: "epoch-1", seq: startSeq }, - endCursor: { epoch: "epoch-1", seq: endSeq }, - hasOlder: true, - hasNewer: false, - entries: [ - { - provider: "claude", - item: { type: "assistant_message", text, messageId: `message-${endSeq}` }, - timestamp: "2026-09-06T13:06:04.431Z", - seqStart: startSeq, - seqEnd: endSeq, - sourceSeqRanges: [{ startSeq, endSeq }], - collapsed: [], - }, - ], - error: null, - }); - const live = (seq: number, text: string) => { - owner.enqueueStreamEvent(AGENT_ID, { - seq, - epoch: "epoch-1", - timestamp: new Date("2026-09-06T13:06:12.797Z"), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text, messageId: `message-${seq}` }, - }, - }); - owner.flushStreamAgent(AGENT_ID); - }; - - try { - owner.applyTimelineResponse(pageAt(265)); - owner.setConnected(true); - owner.registerVisibleAgentIds("workspace", [AGENT_ID]); - await vi.waitFor(() => expect(pending).toHaveLength(1)); - expect(pending[0].request).toMatchObject({ direction: "after", cursor: { seq: 265 } }); - - // The server has already snapshotted through 276, but that response is still in transit. - live(276, "before final response"); - live(284, "final response"); - owner.enqueueStreamEvent(AGENT_ID, { - seq: undefined, - epoch: undefined, - timestamp: new Date("2026-09-06T13:06:12.804Z"), - event: { type: "turn_completed", provider: "claude" }, - }); - owner.flushStreamAgent(AGENT_ID); - expect(pending).toHaveLength(1); - pending[0].respond(page(266, 276, "before final response")); - - // No more live events arrive. The owner must fetch the known missing end itself. - await vi.waitFor(() => expect(pending).toHaveLength(2)); - expect(pending[1].request).toMatchObject({ direction: "after", cursor: { seq: 276 } }); - pending[1].respond(page(277, 284, "final response")); - await vi.waitFor(() => expect(owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready")); - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect([ - ...(session?.agentStreamTail.get(AGENT_ID) ?? []), - ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toMatchObject([ - { text: "network" }, - { text: "before final response" }, - { text: "final response" }, - ]); - expect(durable?.range).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 284 }); - expect(durable?.items.at(-1)).toMatchObject({ text: "final response" }); - } finally { - owner.dispose(); - } - }); - - it("paints a display-only cache when live rows arrive during the disk read", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - let release!: (value: CachedTimeline) => void; - const read = new Promise((resolve) => { - release = resolve; - }); - const replica = createTimelineReplica({ - serverId: SERVER_ID, - storage: { - removeTimeline: () => undefined, - readTimeline: () => read, - commitTimeline: () => undefined, - }, - }); - const preparation = replica.prepare(AGENT_ID); - paintLive(replica, "live", 5); - const liveRows = useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); - release({ ...cachedTimeline(), range: null }); - await preparation; - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect(selectAgentTimelineState(session, AGENT_ID)).toEqual({ - status: "painted", - items: cachedTimeline().items, - }); - expect(session?.agentStreamHead.get(AGENT_ID)).toEqual(liveRows); - expect(replica.readCursor(AGENT_ID)).toBeUndefined(); - }); - - it("reconciles overlapping display-only rows without duplicating saved messages", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - const saved = [item("one", "first", 3), item("two", "second", 4)]; - const replica = createTimelineReplica({ - serverId: SERVER_ID, - storage: { - removeTimeline: () => undefined, - readTimeline: async () => ({ ...cachedTimeline(), items: saved, range: null }), - commitTimeline: () => undefined, - }, - }); - paintLive(replica, "first", 3, "one"); - paintLive(replica, "second", 4, "two"); - paintLive(replica, "live", 5, "live"); - const painted = useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); - await replica.prepare(AGENT_ID); - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect([ - ...(session?.agentStreamTail.get(AGENT_ID) ?? []), - ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toEqual([saved[0], ...(painted?.slice(1) ?? [])]); - expect(replica.readCursor(AGENT_ID)).toBeUndefined(); - }); - - it("retains an assistant continuation while restoring display-only history", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - const replica = createTimelineReplica({ - serverId: SERVER_ID, - storage: { - removeTimeline: () => undefined, - readTimeline: async () => ({ - ...cachedTimeline(), - items: [item("cached", "cached", 4, "msg-1")], - range: null, - }), - commitTimeline: () => undefined, - }, - }); - // The stream resumed mid-message after the save, so the live row holds only the suffix. - paintLive(replica, " and still streaming", 5, "msg-1"); - await replica.prepare(AGENT_ID); - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect([ - ...(session?.agentStreamTail.get(AGENT_ID) ?? []), - ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toMatchObject([{ id: "msg-1", messageId: "msg-1", text: "cached and still streaming" }]); - expect(replica.readCursor(AGENT_ID)).toBeUndefined(); - }); - - it("reopens a bootstrapped window with tools in the order they first appeared", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - let durable: CachedTimeline | undefined; - const storage: TimelineReplicaStorage = { - removeTimeline: () => undefined, - readTimeline: async () => durable, - commitTimeline: (_serverId, _agentId, timeline) => { - durable = timeline; - }, - }; - const owner = createOwner(storage); - const tool = (callId: string, seqStart: number, seqEnd: number) => ({ - provider: "claude", - item: { - type: "tool_call" as const, - callId, - name: "Bash", - status: "completed" as const, - detail: { type: "unknown" as const, input: null, output: null }, - error: null, - }, - timestamp: "2026-08-26T10:00:00.000Z", - seqStart, - seqEnd, - sourceSeqRanges: [ - { startSeq: seqStart, endSeq: seqStart }, - { startSeq: seqEnd, endSeq: seqEnd }, - ], - collapsed: ["tool_lifecycle" as const], - }); - owner.applyTimelineResponse({ - requestId: "bootstrap", - agentId: AGENT_ID, - agent: null, - direction: "tail", - projection: "projected", - reset: true, - epoch: "epoch-1", - window: { minSeq: 1, maxSeq: 700, nextSeq: 701 }, - startCursor: { epoch: "epoch-1", seq: 675 }, - endCursor: { epoch: "epoch-1", seq: 700 }, - entries: [ - { - provider: "claude", - item: { type: "assistant_message", text: "Starting", messageId: "msg-a" }, - timestamp: "2026-08-26T10:00:00.000Z", - seqStart: 675, - seqEnd: 675, - sourceSeqRanges: [{ startSeq: 675, endSeq: 675 }], - collapsed: [], - }, - tool("first", 676, 700), - tool("second", 677, 690), - { - provider: "claude", - item: { type: "assistant_message", text: "Done", messageId: "msg-b" }, - timestamp: "2026-08-26T10:00:00.000Z", - seqStart: 695, - seqEnd: 695, - sourceSeqRanges: [{ startSeq: 695, endSeq: 695 }], - collapsed: [], - }, - ], - error: null, - hasNewer: false, - hasOlder: true, - staleCursor: false, - gap: false, - }); - const startsOf = (rows: StreamItem[]) => - rows.map((row) => `${row.kind}:${row.source?.startSeq}`); - const synced = selectAgentTimelineState( - useSessionStore.getState().sessions[SERVER_ID], - AGENT_ID, - ); - expect(synced.status === "synced" && startsOf(synced.items)).toEqual([ - "assistant_message:675", - "tool_call:676", - "tool_call:677", - "assistant_message:695", - ]); - expect(durable?.range).toEqual({ epoch: "epoch-1", startSeq: 675, endSeq: 700 }); - owner.dispose(); - useSessionStore.getState().clearSession(SERVER_ID); - useSessionStore.getState().initializeSession(SERVER_ID, null); - - const reopened = createTimelineReplica({ serverId: SERVER_ID, storage }); - await reopened.prepare(AGENT_ID); - const painted = selectAgentTimelineState( - useSessionStore.getState().sessions[SERVER_ID], - AGENT_ID, - ); - expect(painted.status === "painted" && startsOf(painted.items)).toEqual([ - "assistant_message:675", - "tool_call:676", - "tool_call:677", - "assistant_message:695", - ]); - expect(reopened.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 700 }); - }); - it("shares an in-flight cache preparation with the viewed owner", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; @@ -498,13 +87,13 @@ describe("viewed timeline persistence", () => { const replica = createTimelineReplica({ serverId: SERVER_ID, storage: { - removeTimeline: () => undefined, readTimeline: () => { reads += 1; return read; }, commitTimeline: () => undefined, }, + prepareAgent: async () => undefined, }); const routePreparation = replica.prepare(AGENT_ID); @@ -519,12 +108,11 @@ describe("viewed timeline persistence", () => { it("paints cached history without claiming authoritative synchronization", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const owner = createOwner({ - removeTimeline: () => undefined, readTimeline: async () => cachedTimeline(), commitTimeline: () => undefined, }); - owner.registerVisibleAgentIds("test", [AGENT_ID]); + owner.replaceVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => @@ -539,7 +127,6 @@ describe("viewed timeline persistence", () => { const partial = "```mermaid\nflowchart LR\n Start --> Mid"; const complete = `${partial}dle\n Middle --> Done\n\`\`\``; const owner = createOwner({ - removeTimeline: () => undefined, readTimeline: async () => ({ agentId: AGENT_ID, items: [item("cached", partial, 4)], @@ -549,7 +136,7 @@ describe("viewed timeline persistence", () => { commitTimeline: () => undefined, }); - owner.registerVisibleAgentIds("test", [AGENT_ID]); + owner.replaceVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), @@ -593,114 +180,18 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); - it("retains the certified baseline across a close during live catch-up", async () => { + it("reopens painted live mutations without persisting authoritative coverage", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); - const savedItems = Array.from({ length: 80 }, (_, index) => - item(`saved-${index + 1}`, `saved ${index + 1}`, index + 1, `msg-${index + 1}`), - ); - let durable: CachedTimeline = { - agentId: AGENT_ID, - items: savedItems, - range: { epoch: "epoch-1", startSeq: 1, endSeq: 80 }, - hasOlder: false, - }; - const storage: TimelineReplicaStorage = { - removeTimeline: () => undefined, - readTimeline: async () => durable, - commitTimeline: (_serverId, _agentId, timeline) => { - durable = timeline; - }, - }; - const first = createOwner(storage); - first.registerVisibleAgentIds("test", [AGENT_ID]); - await expect - .poll(() => - selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), - ) - .toMatchObject({ status: "painted" }); - - // The app receives new live output before its delayed catch-up page fills seq 81. - first.enqueueStreamEvent(AGENT_ID, { - event: { - type: "timeline", - provider: "codex", - item: { type: "assistant_message", text: "live suffix", messageId: "live" }, - } as AgentStreamEventPayload, - seq: 82, - epoch: "epoch-1", - timestamp: new Date("2026-08-26T10:00:01.000Z"), - }); - first.flushStreamAgent(AGENT_ID); - first.dispose(); - useSessionStore.getState().clearSession(SERVER_ID); - useSessionStore.getState().initializeSession(SERVER_ID, null); - - const reopened = createTimelineReplica({ serverId: SERVER_ID, storage }); - await reopened.prepare(AGENT_ID); - - // Saved coverage survives process death; unseen seq 81 is still fetched after 80. - expect(reopened.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 80 }); - const painted = selectAgentTimelineState( - useSessionStore.getState().sessions[SERVER_ID], - AGENT_ID, - ); - expect(painted).toMatchObject({ status: "painted" }); - expect(painted.status === "painted" && painted.items.map((entry) => entry.id)).toEqual( - expect.arrayContaining(savedItems.map((entry) => entry.id)), - ); - }); - - it("keeps the certified snapshot while painted live mutations wait for catch-up", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - const commits: CachedTimeline[] = []; - const storage: TimelineReplicaStorage = { - removeTimeline: () => undefined, - readTimeline: async () => cachedTimeline(), - commitTimeline: (_serverId, _agentId, timeline) => { - commits.push(timeline); - }, - }; - const first = createOwner(storage); - first.registerVisibleAgentIds("test", [AGENT_ID]); - await expect - .poll(() => - selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), - ) - .toMatchObject({ status: "painted" }); - - first.enqueueStreamEvent(AGENT_ID, { - event: { - type: "timeline", - provider: "codex", - item: { type: "assistant_message", text: "live", messageId: "live" }, - } as AgentStreamEventPayload, - seq: 5, - epoch: "epoch-1", - timestamp: new Date("2026-08-26T10:00:01.000Z"), - }); - first.flushStreamAgent(AGENT_ID); - - // The live row is painted but never certified, and the saved certificate is untouched. - expect( - useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID), - ).toMatchObject([{ text: "live" }]); - expect(commits).toEqual([]); - first.dispose(); - }); - - it("reopens painted live mutations of a display-only cache without certifying them", async () => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - let durable: CachedTimeline | undefined = { ...cachedTimeline(), range: null }; + let durable: CachedTimeline | undefined = cachedTimeline(); let pending: CachedTimeline | undefined; const storage: TimelineReplicaStorage = { - removeTimeline: () => undefined, readTimeline: async () => durable, commitTimeline: (_serverId, _agentId, timeline) => { pending = timeline; }, }; const first = createOwner(storage); - first.registerVisibleAgentIds("test", [AGENT_ID]); + first.replaceVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), @@ -727,7 +218,7 @@ describe("viewed timeline persistence", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const reopened = createOwner(storage); - reopened.registerVisibleAgentIds("test", [AGENT_ID]); + reopened.replaceVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), @@ -742,74 +233,6 @@ describe("viewed timeline persistence", () => { reopened.dispose(); }); - it.each(["epoch-1", "obsolete-epoch"])( - "reconciles live rows from %s when display-only history finishes catch-up", - async (liveEpoch) => { - useSessionStore.getState().initializeSession(SERVER_ID, null); - const owner = createOwner({ - removeTimeline: () => undefined, - readTimeline: async () => ({ ...cachedTimeline(), range: null }), - commitTimeline: () => undefined, - }); - owner.registerVisibleAgentIds("test", [AGENT_ID]); - await expect - .poll( - () => - selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID) - .status, - ) - .toBe("painted"); - owner.enqueueStreamEvent(AGENT_ID, { - seq: 6, - epoch: liveEpoch, - timestamp: new Date("2026-08-26T10:00:00.000Z"), - event: { - type: "timeline", - provider: "claude", - item: { type: "assistant_message", text: "live after the snapshot", messageId: "live" }, - }, - }); - owner.flushStreamAgent(AGENT_ID); - const liveHead = useSessionStore - .getState() - .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); - owner.applyTimelineResponse({ - requestId: "display-only-catch-up", - agentId: AGENT_ID, - agent: null, - direction: "tail", - projection: "projected", - reset: true, - epoch: "epoch-1", - window: { minSeq: 1, maxSeq: 5, nextSeq: 6 }, - startCursor: { epoch: "epoch-1", seq: 5 }, - endCursor: { epoch: "epoch-1", seq: 5 }, - entries: [ - { - provider: "mock", - item: { type: "assistant_message", text: "canonical snapshot" }, - timestamp: "2026-08-26T10:00:00.000Z", - seqStart: 5, - seqEnd: 5, - sourceSeqRanges: [{ startSeq: 5, endSeq: 5 }], - collapsed: [], - }, - ], - error: null, - hasNewer: false, - hasOlder: true, - staleCursor: false, - gap: false, - }); - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect(selectAgentTimelineState(session, AGENT_ID).status).toBe("synced"); - expect(session?.agentStreamHead.get(AGENT_ID) ?? []).toEqual( - liveEpoch === "epoch-1" ? liveHead : [], - ); - owner.dispose(); - }, - ); - it("does not let a late cache read overwrite newer network state", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; @@ -817,13 +240,12 @@ describe("viewed timeline persistence", () => { release = resolve; }); const owner = createOwner({ - removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }); - owner.registerVisibleAgentIds("test", [AGENT_ID]); - owner.applyTimelineResponse(pageAt(8)); + owner.replaceVisibleAgentIds("test", [AGENT_ID]); + applySynced(AGENT_ID, 8); release(cachedTimeline()); await expect @@ -843,15 +265,16 @@ describe("viewed timeline persistence", () => { const replica = createTimelineReplica({ serverId: SERVER_ID, storage: { - removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }, + prepareAgent: async () => undefined, }); const preparation = replica.prepare(AGENT_ID); - paintLive(replica, "live", 5); - const liveRows = useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); + useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { + head: [item("live", "live", 5)], + }); release(cachedTimeline()); await preparation; @@ -859,9 +282,9 @@ describe("viewed timeline persistence", () => { expect( selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), ).toEqual({ status: "painted", items: cachedTimeline().items }); - expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID)).toEqual( - liveRows, - ); + expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID)).toEqual([ + item("live", "live", 5), + ]); }); it("reconciles a live head that overlaps the cached canonical timeline", async () => { @@ -873,23 +296,16 @@ describe("viewed timeline persistence", () => { const replica = createTimelineReplica({ serverId: SERVER_ID, storage: { - removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }, + prepareAgent: async () => undefined, }); const preparation = replica.prepare(AGENT_ID); - paintLive(replica, "cached", 4); - const cachedLive = useSessionStore - .getState() - .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID) - ?.at(-1); - paintLive(replica, "live", 5); - const live = useSessionStore - .getState() - .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID) - ?.at(-1); + useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { + head: [item("cached", "cached", 4), item("live", "live", 5)], + }); release(cachedTimeline()); await preparation; @@ -897,7 +313,7 @@ describe("viewed timeline persistence", () => { expect([ ...(session?.agentStreamTail.get(AGENT_ID) ?? []), ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toEqual([cachedLive, live]); + ]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]); expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); }); @@ -910,18 +326,22 @@ describe("viewed timeline persistence", () => { const replica = createTimelineReplica({ serverId: SERVER_ID, storage: { - removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }, + prepareAgent: async () => undefined, }); const preparation = replica.prepare(AGENT_ID); - paintLive(replica, "live", 5); - const live = useSessionStore - .getState() - .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID) - ?.at(-1); + useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, { + items: [item("live", "live", 5)], + head: [], + range: null, + older: "none", + newer: false, + synchronized: false, + acknowledgedClientMessageIds: [], + }); release(cachedTimeline()); await preparation; @@ -929,20 +349,18 @@ describe("viewed timeline persistence", () => { expect([ ...(session?.agentStreamTail.get(AGENT_ID) ?? []), ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toEqual([item("cached", "cached", 4), live]); + ]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]); expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); }); it("persists accepted live stream commits through the owner", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); + applySynced(AGENT_ID, 8); const commits: CachedTimeline[] = []; const owner = createOwner({ - removeTimeline: () => undefined, readTimeline: async () => undefined, commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline), }); - owner.applyTimelineResponse(pageAt(8)); - commits.length = 0; owner.enqueueStreamEvent(AGENT_ID, { event: { type: "timeline", @@ -964,7 +382,6 @@ describe("viewed timeline persistence", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const keys: string[] = []; const owner = createOwner({ - removeTimeline: () => undefined, readTimeline: async () => undefined, commitTimeline: (_serverId, agentId) => keys.push(agentId), }); @@ -999,14 +416,12 @@ describe("viewed timeline persistence", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const keys: string[] = []; const owner = createOwner({ - removeTimeline: () => undefined, readTimeline: async () => undefined, commitTimeline: (_serverId, agentId) => keys.push(agentId), }); - owner.applyTimelineResponse(pageAt(8)); - owner.applyTimelineResponse({ ...pageAt(3), agentId: "agent-2" }); - keys.length = 0; + applySynced(AGENT_ID, 8); + applySynced("agent-2", 3); for (const [agentId, seq] of [ [AGENT_ID, 9], ["agent-2", 4], @@ -1028,3 +443,112 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); }); + +function createSqliteCache() { + const database = new DatabaseSync(":memory:"); + let beforeRead = async () => {}; + const connection: ReplicaSqliteConnection = { + async exec(sql) { + database.exec(sql); + }, + async run(sql, params = []) { + database.prepare(sql).run(...params); + }, + async all(sql: string, params: readonly SqliteValue[] = []) { + const rows = database.prepare(sql).all(...params) as Row[]; + if (sql.includes("FROM rows") && sql.includes("WHERE")) await beforeRead(); + return rows; + }, + async transaction(operation) { + database.exec("BEGIN IMMEDIATE"); + try { + await operation(connection); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + }, + }; + const storage = createSqliteReplicaRowStore({ open: async () => connection }, 1); + const cache = new ReplicaCache(storage, { clearLegacyCache: async () => {} }); + return { + cache, + database, + holdRead: (operation: () => Promise) => { + beforeRead = operation; + }, + }; +} + +describe("SQLite baseline restoration", () => { + it.each([false, true])( + "keeps display-only cached history under a live event (synced=%s)", + async (synced) => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const { cache, database, holdRead } = createSqliteCache(); + cache.setHosts([SERVER_ID]); + cache.commitTimeline(SERVER_ID, AGENT_ID, { + ...cachedTimeline(), + items: [item("earlier", "earlier", 1), ...cachedTimeline().items], + range: null, + }); + await cache.flush(); + holdRead(async () => { + if (synced) applySynced(AGENT_ID, 8); + else + useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { + head: [item("cached", "cached", 4), item("live", "live", 5)], + }); + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: cache, + prepareAgent: async () => {}, + }); + await replica.prepare(AGENT_ID); + const session = useSessionStore.getState().sessions[SERVER_ID]; + const timeline = selectAgentTimelineState(session, AGENT_ID); + expect(timeline.status).toBe(synced ? "synced" : "painted"); + expect([ + ...(timeline.status === "cold" ? [] : timeline.items), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ]).toEqual( + synced + ? [item(`network-${AGENT_ID}`, "network", 8)] + : [item("earlier", "earlier", 1), item("cached", "cached", 4), item("live", "live", 5)], + ); + expect(replica.readCursor(AGENT_ID)).toBeUndefined(); + database.close(); + }, + ); + + it("starts the timeline disk read while agent preparation is pending", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const { cache, database, holdRead } = createSqliteCache(); + cache.setHosts([SERVER_ID]); + cache.commitTimeline(SERVER_ID, AGENT_ID, cachedTimeline()); + await cache.flush(); + let readStarted = false; + let release!: () => void; + const agentReady = new Promise((resolve) => { + release = resolve; + }); + holdRead(async () => { + readStarted = true; + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: cache, + prepareAgent: () => agentReady, + }); + const preparation = replica.prepare(AGENT_ID); + try { + await expect.poll(() => readStarted, { timeout: 500 }).toBe(true); + } finally { + release(); + await preparation; + database.close(); + } + }); +}); diff --git a/packages/app/src/timeline/replica.ts b/packages/app/src/timeline/replica.ts deleted file mode 100644 index b4ecb4ed43..0000000000 --- a/packages/app/src/timeline/replica.ts +++ /dev/null @@ -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 | undefined; -} - -export interface TimelineReplicaStorage { - removeTimeline(serverId: string, agentId: string): void; - readTimeline(serverId: string, agentId: string): Promise; - 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; - 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, -): 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(); - private readonly removed = new Set(); - 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 { - 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 { - 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[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, -): 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); - } -} diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 42f07860cf..13897f8454 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -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]); - }); -}); diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index cb7a2a663b..b04ad00f02 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1,25 +1,16 @@ -import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; -import type { - AssistantMessageItem, - StreamItem, - SourcedPosition, - ThoughtItem, - TodoEntry, - UserMessageItem, -} from "@/types/stream"; +import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; +import type { AssistantMessageItem, StreamItem, TodoEntry } from "@/types/stream"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { applyStreamEvent, + flushHeadToTail, hydrateStreamState, isAgentToolCallItem, - joinTextRows, mergeAgentToolCallItem, - mergeCanonicalText, replaceWithCanonicalStream, reduceStreamUpdate, - rowStartSeq, streamTimelineItemIdentity, upsertUserMessageAcrossStream, } from "@/types/stream"; @@ -50,14 +41,12 @@ export interface TimelineCursor { // --------------------------------------------------------------------------- export type TimelineReducerSideEffect = - // `observedSeq` is the sequence the live stream reached that coverage must still catch up to. - | { type: "catch_up"; cursor: { epoch: string; endSeq: number }; observedSeq?: number } + | { type: "catch_up"; cursor: { epoch: string; endSeq: number } } | { type: "flush_pending_updates" }; export interface AgentStreamReducerSideEffect { type: "catch_up"; cursor: { epoch: string; endSeq: number }; - observedSeq?: number; } // --------------------------------------------------------------------------- @@ -147,7 +136,6 @@ interface TimelineResponseEntry { } export interface ProcessTimelineResponseInput { - replaceTail?: boolean; payload: { agentId: string; direction: TimelineDirection; @@ -194,12 +182,6 @@ interface TimelineUnit { timestamp: Date; } -interface HydratedTimelineEvent { - event: AgentStreamEventPayload; - timestamp: Date; - timelineCursor: SourcedPosition; -} - interface TimelinePathResult { tail: StreamItem[]; head: StreamItem[]; @@ -324,7 +306,6 @@ function classifySessionTimelineSeq({ function deriveBootstrapTailTimelinePolicy({ direction, reset, - replaceTail, epoch, endCursor, isInitializing, @@ -332,7 +313,6 @@ function deriveBootstrapTailTimelinePolicy({ }: { direction: TimelineDirection; reset: boolean; - replaceTail: boolean; epoch: string; endCursor: { seq: number } | null; isInitializing: boolean; @@ -341,7 +321,7 @@ function deriveBootstrapTailTimelinePolicy({ replace: boolean; catchUpCursor: { epoch: string; endSeq: number } | null; } { - if (reset || (replaceTail && direction === "tail")) { + if (reset) { return { replace: true, catchUpCursor: null }; } @@ -408,7 +388,11 @@ function mergeTimelineWindow(args: { currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; - toHydratedEvents: (units: TimelineUnit[]) => HydratedTimelineEvent[]; + toHydratedEvents: (units: TimelineUnit[]) => Array<{ + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: { epoch: string; seq: number }; + }>; }): TimelinePathResult { const { timelineUnits, payload, currentTail, currentHead, currentCursor, toHydratedEvents } = args; @@ -460,9 +444,9 @@ function mergeTimelineWindow(args: { const tail = [...reconciled.tail, ...reconciled.page] .map((item, order) => ({ item, order })) .sort((left, right) => { - const leftStart = rowStartSeq(left.item) ?? Number.POSITIVE_INFINITY; - const rightStart = rowStartSeq(right.item) ?? Number.POSITIVE_INFINITY; - return leftStart - rightStart || left.order - right.order; + const leftSeq = left.item.timelineCursor?.seq ?? Number.POSITIVE_INFINITY; + const rightSeq = right.item.timelineCursor?.seq ?? Number.POSITIVE_INFINITY; + return leftSeq - rightSeq || left.order - right.order; }) .map(({ item }) => item); const cursor = mergeTimelineCoverage(currentCursor, { @@ -517,58 +501,17 @@ function shouldResolveTimelineInit({ return responseDirection === initRequestDirection; } -export function processTimelineResponseCompletion( - input: Pick< - ProcessTimelineResponseInput, - "payload" | "isInitializing" | "hasActiveInitDeferred" | "initRequestDirection" - >, -): Pick { - const { payload, isInitializing, hasActiveInitDeferred, initRequestDirection } = input; - if (payload.error) - return { - initResolution: hasActiveInitDeferred ? "reject" : null, - clearInitializing: isInitializing, - error: payload.error, - }; - const resolves = shouldResolveTimelineInit({ - hasActiveInitDeferred, - hasNewer: payload.hasNewer, - isInitializing, - initRequestDirection, - responseDirection: payload.direction, - reset: payload.reset, - }); - const complete = payload.direction !== "after" || !payload.hasNewer; - return { - error: null, - initResolution: resolves ? "resolve" : null, - clearInitializing: (resolves || (isInitializing && !hasActiveInitDeferred)) && complete, - }; -} - -// A tail whose window ends behind the local cursor is a rewind: the daemon no longer has the rows -// between its head and that cursor, so the response supersedes everything up to the cursor it -// rewound behind. Any other page certifies only the positions it carries. -function resolveCanonicalCoverageEnd( - payload: ProcessTimelineResponseInput["payload"], - currentCursor: TimelineCursor | undefined, -): number | null { - if (currentCursor?.epoch === payload.epoch && payload.window.maxSeq < currentCursor.endSeq) { - return currentCursor.endSeq; - } - return payload.endCursor?.seq ?? null; -} - function applyTimelineReplacePath(args: { timelineUnits: TimelineUnit[]; payload: ProcessTimelineResponseInput["payload"]; bootstrapPolicy: ReturnType; currentTail: StreamItem[]; currentHead: StreamItem[]; - currentCursor: TimelineCursor | undefined; sendingClientMessageIds: readonly string[]; preserveContinuity: boolean; - toHydratedEvents: (units: TimelineUnit[]) => HydratedTimelineEvent[]; + toHydratedEvents: ( + units: TimelineUnit[], + ) => Array<{ event: AgentStreamEventPayload; timestamp: Date }>; }): TimelinePathResult { const { timelineUnits, @@ -576,7 +519,6 @@ function applyTimelineReplacePath(args: { bootstrapPolicy, currentTail, currentHead, - currentCursor, sendingClientMessageIds, preserveContinuity, toHydratedEvents, @@ -592,7 +534,7 @@ function applyTimelineReplacePath(args: { preserveContinuity, canonicalCoverage: { epoch: payload.epoch, - endSeq: resolveCanonicalCoverageEnd(payload, currentCursor), + endSeq: payload.endCursor?.seq ?? null, }, }); const cursor: TimelineCursor | null = @@ -756,17 +698,14 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea const identityMerge = mergeTimelineIdentityBoundary(olderTail, currentTail); if (identityMerge) return identityMerge; - if ( - olderLast?.kind !== "assistant_message" || - currentFirst?.kind !== "assistant_message" || - (olderLast.messageId !== undefined && - currentFirst.messageId !== undefined && - olderLast.messageId !== currentFirst.messageId) - ) { + if (olderLast?.kind !== "assistant_message" || currentFirst?.kind !== "assistant_message") { return [...olderTail, ...currentTail]; } - const mergedAssistant: AssistantMessageItem = joinTextRows(olderLast, currentFirst); + const mergedAssistant: AssistantMessageItem = { + ...currentFirst, + text: `${olderLast.text}${currentFirst.text}`, + }; if (mergedAssistant.messageId === undefined && olderLast.messageId !== undefined) { mergedAssistant.messageId = olderLast.messageId; } @@ -818,372 +757,161 @@ function mergeOlderTimelinePage(input: { return [...retainedBefore, ...mergePrependedCanonicalTail(input.page, currentAtOrAfterPage)]; } -// --------------------------------------------------------------------------- -// Forward pages merge into the ordered timeline -// -// Both lanes together are one sequence ordered by source start. A canonical unit first looks for -// the rows it owns: text units own rows sharing their provider message id or holding a chunk -// inside their source coverage, tool and plugin units own rows with their timeline identity, user -// units own the local row they acknowledge, and every other unit owns the row of its kind at its -// start position. An owned row is replaced or merged where it stands. A unit nobody owns is -// inserted at its start position, so history missed while the client was away lands before rows -// the live stream delivered later, and a tool call keeps the position it first appeared at even -// though its completion carries a newer sequence. -// --------------------------------------------------------------------------- - -interface OrderedLanes { - rows: StreamItem[]; - // rows[0, boundary) is the tail lane; the rest is the live head. - boundary: number; +function replaceLiveAssistantWithProjectedText(params: { + head: StreamItem[]; + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: { epoch: string; seq: number }; +}): StreamItem[] | null { + const { head, event, timestamp, timelineCursor } = params; + if (event.type !== "timeline" || event.item.type !== "assistant_message") { + return null; + } + const index = head.findLastIndex((item) => item.kind === "assistant_message"); + const current = head[index]; + if (!current || current.kind !== "assistant_message") { + return null; + } + if (!event.item.text.startsWith(current.text)) { + return null; + } + const next = [...head]; + next[index] = { + ...current, + text: event.item.text, + timestamp, + timelineCursor, + }; + return next; } -function unitPosition(unit: TimelineUnit, epoch: string): SourcedPosition { - return { epoch, seq: unit.seqEnd, startSeq: unit.seq }; -} - -function insertionIndex(rows: readonly StreamItem[], epoch: string, startSeq: number): number { - const index = rows.findIndex((row) => { - const start = rowStartSeq(row); - return row.timelineCursor?.epoch === epoch && start !== undefined && start > startSeq; - }); - return index < 0 ? rows.length : index; -} - -function spliceLanes( - lanes: OrderedLanes, - index: number, - removeCount: number, - inserted: readonly StreamItem[], -): OrderedLanes { - const rows = [...lanes.rows]; - rows.splice(index, removeCount, ...inserted); - const removedFromTail = Math.max(0, Math.min(removeCount, lanes.boundary - index)); - // Rows land in the tail when they replace tail rows, sit before the head, or when there is no - // live head to join. - const insertedIntoTail = - index < lanes.boundary || removedFromTail > 0 || lanes.boundary === lanes.rows.length - ? inserted.length - : 0; - return { rows, boundary: lanes.boundary - removedFromTail + insertedIntoTail }; -} - -function hydrateUnitAfter( - prefix: readonly StreamItem[], - unit: TimelineUnit, - epoch: string, - reservedItemIds?: ReadonlySet, -): StreamItem[] { - return reduceStreamUpdate([...prefix], unit.event, unit.timestamp, { - source: "canonical", - timelineCursor: unitPosition(unit, epoch), - reservedItemIds, - }); -} - -function textUnitKind(unit: TimelineUnit): "assistant_message" | "thought" | null { - if (unit.event.type !== "timeline") return null; - if (unit.event.item.type === "assistant_message") return "assistant_message"; - if (unit.event.item.type === "reasoning") return "thought"; - return null; -} - -function unitText(unit: TimelineUnit): string { - if (unit.event.type !== "timeline") return ""; - const item = unit.event.item; - return item.type === "assistant_message" || item.type === "reasoning" ? item.text : ""; -} - -function unitMessageId(unit: TimelineUnit): string | undefined { - return unit.event.type === "timeline" && unit.event.item.type === "assistant_message" - ? unit.event.item.messageId - : undefined; -} - -function straddlesAuthoritativeCursor( - unit: TimelineUnit, - currentEndSeq: number | undefined, -): boolean { - return ( - currentEndSeq !== undefined && - unit.sourceSeqRanges.some( - (range) => range.startSeq <= currentEndSeq && range.endSeq > currentEndSeq, +function reconcileOverlappingProjectedAssistant(params: { + tail: StreamItem[]; + head: StreamItem[]; + unit: TimelineUnit; + epoch: string; + currentEndSeq: number; +}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } { + const { unit } = params; + if ( + unit.event.type !== "timeline" || + unit.event.item.type !== "assistant_message" || + !unit.sourceSeqRanges.some( + (range) => range.startSeq <= params.currentEndSeq && range.endSeq > params.currentEndSeq, ) - ); -} - -// Identified unpositioned markdown blocks form a contiguous segment, not an entire message: -// a tool can divide two segments with the same message id. Block text resolves coverage only -// after identity matches. Ambiguous repeated segments remain unverified rather than guessed away. -function unpositionedTextRowIndexes( - rows: readonly StreamItem[], - unit: TimelineUnit, - epoch: string, -): number[] { - const messageId = unitMessageId(unit); - if (!messageId) return []; - const canonical = splitMarkdownBlocks(unitText(unit)).join("\n\n"); - const candidates: number[][] = []; - for (let index = 0; index < rows.length; index++) { - const indexes: number[] = []; - const blocks: string[] = []; - while (index < rows.length) { - const row = rows[index]!; - if (row.kind !== "assistant_message" || row.messageId !== messageId || row.timelineCursor) - break; - indexes.push(index++); - blocks.push(...splitMarkdownBlocks(row.text)); - } - if (indexes.length === 0) continue; - const previous = rows[indexes[0]! - 1]; - const next = rows[index]; - const afterStart = - previous?.timelineCursor?.epoch === epoch && rowStartSeq(previous)! > unit.seq; - const beforeStart = next?.timelineCursor?.epoch === epoch && rowStartSeq(next)! <= unit.seq; - const live = blocks.join("\n\n"); - if (!afterStart && !beforeStart && (canonical.startsWith(live) || live.startsWith(canonical))) { - candidates.push(indexes); - } + ) { + return { tail: params.tail, head: params.head, reconciled: false }; } - return candidates.length === 1 ? candidates[0]! : []; -} -// Positioned units own covered chunks and adjacent continuations. Idless unpositioned rows keep -// the conservative cursor-straddle rule; an identified segment does not need cursor straddling. -function ownedTextRowIndexes( - rows: readonly StreamItem[], - unit: TimelineUnit, - epoch: string, - currentEndSeq: number | undefined, -): number[] { - const kind = textUnitKind(unit); - if (!kind) return []; - const messageId = unitMessageId(unit); - const sameMessage = (row: StreamItem): boolean => - row.kind === kind && - (row.kind !== "assistant_message" || - !messageId || - !row.messageId || - row.messageId === messageId); - const covered = rows - .map((row, index) => - sameMessage(row) && - row.timelineCursor?.epoch === epoch && - rowSourceSeqs(row).some((seq) => seq >= unit.seq && seq <= unit.seqEnd) - ? index - : -1, - ) - .filter((index) => index >= 0); - if (covered.length === 0) { - const unpositioned = unpositionedTextRowIndexes(rows, unit, epoch); - if (unpositioned.length > 0) return unpositioned; - } - const [firstCovered, lastCovered] = [covered[0], covered.at(-1)]; - const neighbours = - firstCovered !== undefined && lastCovered !== undefined - ? [firstCovered - 1, lastCovered + 1] - : (() => { - const index = insertionIndex(rows, epoch, unit.seq); - return [index - 1, index]; - })(); - const owned = new Set(covered); - if (messageId) { - for (const index of neighbours) { - const row = rows[index]; - if (row?.kind === "assistant_message" && row.messageId === messageId && row.timelineCursor) - owned.add(index); - } - } - if (owned.size > 0) return [...owned].sort((left, right) => left - right); - if (!straddlesAuthoritativeCursor(unit, currentEndSeq)) return []; - const text = unitText(unit); - const legacy = rows.findLastIndex( - (row) => - row.kind === kind && - !row.timelineCursor && - (!messageId || row.kind !== "assistant_message" || !row.messageId) && - text.startsWith(row.text), - ); - return legacy >= 0 ? [legacy] : []; -} + const projectedText = unit.event.item.text; + const projectedMessageId = unit.event.item.messageId; + const matches = (item: StreamItem) => { + if (item.kind !== "assistant_message") return false; + if (projectedMessageId && item.messageId) return item.messageId === projectedMessageId; + return projectedText.startsWith(item.text); + }; + const findMatch = (items: StreamItem[]) => { + const index = items.findLastIndex(matches); + const current = items[index]; + return current?.kind === "assistant_message" ? { current, index } : null; + }; -function rowSourceSeqs(row: StreamItem): number[] { - const chunks = - row.kind === "assistant_message" || row.kind === "thought" ? row.source?.chunks : undefined; - if (chunks) return chunks.map((chunk) => chunk.seq); - return row.timelineCursor ? [row.timelineCursor.seq] : []; -} - -// Fully accepted identified segments use the same representation as fresh hydration. Reserve -// other rows' identities, not the transient markdown blocks this unit replaces. -function replaceTextUnit( - lanes: OrderedLanes, - indexes: number[], - unit: TimelineUnit, - epoch: string, -): OrderedLanes { - const first = indexes[0]!; - const last = indexes.at(-1)!; - const otherRows = lanes.rows.filter((_row, index) => index < first || index > last); - const reserved = new Set( - otherRows.flatMap((row) => - row.kind === "assistant_message" && row.blockGroupId ? [row.id, row.blockGroupId] : [row.id], - ), - ); - return spliceLanes(lanes, first, last - first + 1, hydrateUnitAfter([], unit, epoch, reserved)); -} - -function applyTextUnit( - lanes: OrderedLanes, - unit: TimelineUnit, - epoch: string, - currentEndSeq: number | undefined, -): { lanes: OrderedLanes; owned: boolean } { - const kind = textUnitKind(unit); - if (!kind) return { lanes, owned: false }; - const ownedIndexes = ownedTextRowIndexes(lanes.rows, unit, epoch, currentEndSeq); - if (ownedIndexes.length === 0) { - return { lanes: insertUnitInOrder(lanes, unit, epoch), owned: false }; + const headMatch = findMatch(params.head); + const tailMatch = headMatch ? null : findMatch(params.tail); + const match = headMatch ?? tailMatch; + if (!match) { + return { tail: params.tail, head: params.head, reconciled: false }; } - const owned = ownedIndexes - .map((index) => lanes.rows[index]) - .filter((row): row is AssistantMessageItem | ThoughtItem => row?.kind === kind); - const messageId = unitMessageId(unit); - if (messageId && owned.every((row) => !row.timelineCursor)) { - const canonical = splitMarkdownBlocks(unitText(unit)).join("\n\n"); - const live = owned.flatMap((row) => splitMarkdownBlocks(row.text)).join("\n\n"); - // A lagging canonical prefix cannot position text delivered without a cursor. Keep the - // newer segment unverified; otherwise replace all its markdown blocks with exact page text. - if (live.length > canonical.length && live.startsWith(canonical)) return { lanes, owned: true }; - return { lanes: replaceTextUnit(lanes, ownedIndexes, unit, epoch), owned: true }; - } - const fullyCovered = owned.every( - (row) => - row.timelineCursor?.epoch === epoch && - rowStartSeq(row)! >= unit.seq && - row.timelineCursor.seq <= unit.seqEnd, - ); - if (messageId && fullyCovered) - return { lanes: replaceTextUnit(lanes, ownedIndexes, unit, epoch), owned: true }; - const merged: StreamItem[] = []; - for (const row of mergeCanonicalText(owned, { - text: unitText(unit), - epoch, - startSeq: unit.seq, - endSeq: unit.seqEnd, + + const blockGroupId = match.current.blockGroupId; + const messageId = projectedMessageId ?? match.current.messageId; + const replacement: AssistantMessageItem = { + kind: "assistant_message", + id: blockGroupId ?? match.current.id, + ...(messageId !== undefined ? { messageId } : {}), + text: projectedText, timestamp: unit.timestamp, - })) { - merged.push( - row.kind === "assistant_message" && messageId && row.messageId === undefined - ? { ...row, messageId } - : row, - ); - } - // Owned rows are adjacent by construction; the merge takes their place. - const first = ownedIndexes[0]!; - const last = ownedIndexes.at(-1)!; - return { lanes: spliceLanes(lanes, first, last - first + 1, merged), owned: true }; -} + timelineCursor: { epoch: params.epoch, seq: unit.seqEnd }, + }; + const belongsToBlockGroup = (item: StreamItem) => + blockGroupId !== undefined && + item.kind === "assistant_message" && + item.blockGroupId === blockGroupId; + const removeBlockGroup = (items: StreamItem[]) => + blockGroupId !== undefined ? items.filter((item) => !belongsToBlockGroup(item)) : items; + const replaceMatch = (items: StreamItem[], index: number) => { + if (!blockGroupId) { + const next = [...items]; + next[index] = replacement; + return next; + } + const next: StreamItem[] = []; + let inserted = false; + for (const item of items) { + if (!belongsToBlockGroup(item)) { + next.push(item); + } else if (!inserted) { + next.push(replacement); + inserted = true; + } + } + return next; + }; -// Reducing against the rows before the position lets kind-specific rules (todo diffs, -// compaction completion, idless assistant continuation) see their predecessor while the unit -// still lands in source order. Ids stay unique against the rows after it. -function insertUnitInOrder(lanes: OrderedLanes, unit: TimelineUnit, epoch: string): OrderedLanes { - const index = insertionIndex(lanes.rows, epoch, unit.seq); - const rest = lanes.rows.slice(index); - const identifiedText = Boolean(unitMessageId(unit)); - const reserved = new Set( - (identifiedText ? lanes.rows : rest).flatMap((row) => - row.kind === "assistant_message" && row.blockGroupId ? [row.id, row.blockGroupId] : [row.id], - ), - ); - // Reconciliation already declined ownership. Do not let ordinary live continuation merge - // this identified canonical segment into an ambiguous same-id predecessor during insertion. - if (identifiedText) - return spliceLanes(lanes, index, 0, hydrateUnitAfter([], unit, epoch, reserved)); - const created = hydrateUnitAfter(lanes.rows.slice(0, index), unit, epoch, reserved); - const prefixReplaced = { ...lanes, rows: [...created.slice(0, index), ...rest] }; - return spliceLanes(prefixReplaced, index, 0, created.slice(index)); -} - -function applyUserUnit( - lanes: OrderedLanes, - unit: TimelineUnit, - epoch: string, -): { lanes: OrderedLanes; acknowledgedClientMessageIds: string[] } { - if (unit.event.type !== "timeline" || unit.event.item.type !== "user_message") { - return { lanes, acknowledgedClientMessageIds: [] }; + if (headMatch) { + return { + tail: removeBlockGroup(params.tail), + head: replaceMatch(params.head, headMatch.index), + reconciled: true, + }; } - const probe = hydrateUnitAfter([], unit, epoch).find( - (row): row is UserMessageItem => row.kind === "user_message", - ); - if (!probe) return { lanes, acknowledgedClientMessageIds: [] }; - const matched = upsertUserMessageAcrossStream({ - tail: lanes.rows.slice(0, lanes.boundary), - head: lanes.rows.slice(lanes.boundary), - message: probe, - insert: "none", - presentation: "existing", - }); - const location = matched.location; - if (!location?.matched) { - return { lanes: insertUnitInOrder(lanes, unit, epoch), acknowledgedClientMessageIds: [] }; - } - const existingIndex = location.lane === "tail" ? location.index : lanes.boundary + location.index; - const message = withCanonicalTurn(location.message, unit.event.turnId); - const removed = spliceLanes(lanes, existingIndex, 1, []); - const index = insertionIndex(removed.rows, epoch, unit.seq); return { - lanes: spliceLanes(removed, index, 0, [message]), - acknowledgedClientMessageIds: message.clientMessageId ? [message.clientMessageId] : [], + tail: replaceMatch(params.tail, match.index), + head: removeBlockGroup(params.head), + reconciled: true, }; } -// A canonical user row is authoritative for turn membership. -function withCanonicalTurn(message: UserMessageItem, turnId: string | undefined): UserMessageItem { - if (message.turnId === turnId) return message; - if (turnId) return { ...message, turnId }; - const { turnId: _, ...withoutTurn } = message; - return withoutTurn; -} - -function applyOtherUnit(lanes: OrderedLanes, unit: TimelineUnit, epoch: string): OrderedLanes { - const probe = hydrateUnitAfter([], unit, epoch).at(-1); - if (!probe) return lanes; - const identity = streamTimelineItemIdentity(probe); - const existingIndex = lanes.rows.findIndex((row) => - identity !== null - ? streamTimelineItemIdentity(row) === identity - : row.kind === probe.kind && - row.timelineCursor?.epoch === epoch && - rowStartSeq(row) === unit.seq, - ); - if (existingIndex >= 0) { - const existing = lanes.rows[existingIndex]!; - const replacement = - probe.kind === "notification" - ? { ...probe, id: existing.id } - : (hydrateUnitAfter([existing], unit, epoch).at(-1) ?? existing); - return spliceLanes(lanes, existingIndex, 1, [{ ...replacement, id: existing.id }]); +function reconcileOverlappingProjectedReasoning(params: { + tail: StreamItem[]; + head: StreamItem[]; + unit: TimelineUnit; + currentEndSeq: number; +}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } { + const { unit } = params; + if ( + unit.event.type !== "timeline" || + unit.event.item.type !== "reasoning" || + !unit.sourceSeqRanges.some( + (range) => range.startSeq <= params.currentEndSeq && range.endSeq > params.currentEndSeq, + ) + ) { + return { tail: params.tail, head: params.head, reconciled: false }; } - return insertUnitInOrder(lanes, unit, epoch); -} -function applyForwardUnit( - lanes: OrderedLanes, - unit: TimelineUnit, - epoch: string, - currentEndSeq: number | undefined, -): { lanes: OrderedLanes; acknowledgedClientMessageIds: string[] } { - if (textUnitKind(unit)) { - return { - lanes: applyTextUnit(lanes, unit, epoch, currentEndSeq).lanes, - acknowledgedClientMessageIds: [], + const projectedText = unit.event.item.text; + const replaceIn = (items: StreamItem[]): StreamItem[] | null => { + const index = items.findLastIndex( + (item) => item.kind === "thought" && projectedText.startsWith(item.text), + ); + const current = items[index]; + if (!current || current.kind !== "thought") return null; + const next = [...items]; + next[index] = { + ...current, + text: projectedText, + timestamp: unit.timestamp, + status: "loading", }; - } - if (unit.event.type === "timeline" && unit.event.item.type === "user_message") { - return applyUserUnit(lanes, unit, epoch); - } - return { lanes: applyOtherUnit(lanes, unit, epoch), acknowledgedClientMessageIds: [] }; + return next; + }; + + const nextHead = replaceIn(params.head); + if (nextHead) return { tail: params.tail, head: nextHead, reconciled: true }; + const nextTail = replaceIn(params.tail); + return nextTail + ? { tail: nextTail, head: params.head, reconciled: true } + : { tail: params.tail, head: params.head, reconciled: false }; } function reconcileOverlappingProjectedStreamItems(params: { @@ -1193,22 +921,109 @@ function reconcileOverlappingProjectedStreamItems(params: { epoch: string; currentEndSeq: number | undefined; }): { tail: StreamItem[]; head: StreamItem[]; reconciledUnits: Set } { - let lanes: OrderedLanes = { - rows: [...params.tail, ...params.head], - boundary: params.tail.length, - }; + let tail = params.tail; + let head = params.head; const reconciledUnits = new Set(); + if (params.currentEndSeq === undefined) return { tail, head, reconciledUnits }; + for (const unit of params.units) { - if (!textUnitKind(unit)) continue; - if (ownedTextRowIndexes(lanes.rows, unit, params.epoch, params.currentEndSeq).length === 0) - continue; - lanes = applyTextUnit(lanes, unit, params.epoch, params.currentEndSeq).lanes; - reconciledUnits.add(unit); + let reconciled = reconcileOverlappingProjectedAssistant({ + tail, + head, + unit, + epoch: params.epoch, + currentEndSeq: params.currentEndSeq, + }); + if (!reconciled.reconciled) { + reconciled = reconcileOverlappingProjectedReasoning({ + tail, + head, + unit, + currentEndSeq: params.currentEndSeq, + }); + } + tail = reconciled.tail; + head = reconciled.head; + if (reconciled.reconciled) reconciledUnits.add(unit); } + return { tail, head, reconciledUnits }; +} + +function applyCanonicalForwardUnit(params: { + tail: StreamItem[]; + head: StreamItem[]; + unit: TimelineUnit; + epoch: string; +}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { + const { event, timestamp, seqEnd } = params.unit; + const timelineCursor = { epoch: params.epoch, seq: seqEnd }; + if (event.type === "timeline" && event.item.type === "user_message") { + const applied = applyStreamEvent({ + tail: params.tail, + head: params.head, + event, + timestamp, + source: "canonical", + timelineCursor, + }); + return { + tail: applied.tail, + head: applied.head, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], + }; + } + if (params.head.length === 0) { + return { + tail: reduceStreamUpdate(params.tail, event, timestamp, { + source: "canonical", + timelineCursor, + }), + head: params.head, + acknowledgedClientMessageIds: [], + }; + } + const replacedHead = replaceLiveAssistantWithProjectedText({ + head: params.head, + event, + timestamp, + timelineCursor, + }); + if (replacedHead) { + return { tail: params.tail, head: replacedHead, acknowledgedClientMessageIds: [] }; + } + + const activeAssistant = params.head.findLast( + (item): item is Extract => + item.kind === "assistant_message", + ); + if ( + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.messageId !== undefined && + event.item.messageId !== activeAssistant?.messageId + ) { + return { + tail: flushHeadToTail(params.tail, params.head), + head: reduceStreamUpdate([], event, timestamp, { + source: "canonical", + timelineCursor, + }), + acknowledgedClientMessageIds: [], + }; + } + + const applied = applyStreamEvent({ + tail: params.tail, + head: params.head, + event, + timestamp, + source: "canonical", + timelineCursor, + }); return { - tail: lanes.rows.slice(0, lanes.boundary), - head: lanes.rows.slice(lanes.boundary), - reconciledUnits, + tail: applied.tail, + head: applied.head, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], }; } @@ -1219,22 +1034,37 @@ function applyAcceptedForwardTimelineUnits(params: { currentHead: StreamItem[]; currentEndSeq: number | undefined; }): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { - let lanes: OrderedLanes = { - rows: [...params.currentTail, ...params.currentHead], - boundary: params.currentTail.length, - }; - const acknowledged = new Set(); + const reconciled = reconcileOverlappingProjectedStreamItems({ + tail: params.currentTail, + head: params.currentHead, + units: params.units, + epoch: params.epoch, + currentEndSeq: params.currentEndSeq, + }); + let tail = reconciled.tail; + let head = reconciled.head; + for (const unit of params.units) { - const applied = applyForwardUnit(lanes, unit, params.epoch, params.currentEndSeq); - lanes = applied.lanes; - for (const clientMessageId of applied.acknowledgedClientMessageIds) { - acknowledged.add(clientMessageId); - } + if (reconciled.reconciledUnits.has(unit)) continue; + const applied = applyCanonicalForwardUnit({ + tail, + head, + unit, + epoch: params.epoch, + }); + tail = applied.tail; + head = applied.head; } + return { - tail: lanes.rows.slice(0, lanes.boundary), - head: lanes.rows.slice(lanes.boundary), - acknowledgedClientMessageIds: [...acknowledged], + tail, + head, + acknowledgedClientMessageIds: deriveCanonicalAcknowledgements({ + units: params.units, + epoch: params.epoch, + currentTail: params.currentTail, + currentHead: params.currentHead, + }), }; } @@ -1244,15 +1074,14 @@ function deriveCanonicalAcknowledgements(params: { currentTail: StreamItem[]; currentHead: StreamItem[]; }): string[] { - let lanes: OrderedLanes = { - rows: [...params.currentTail, ...params.currentHead], - boundary: params.currentTail.length, - }; + let tail = params.currentTail; + let head = params.currentHead; const acknowledged = new Set(); for (const unit of params.units) { if (unit.event.type !== "timeline" || unit.event.item.type !== "user_message") continue; - const applied = applyUserUnit(lanes, unit, params.epoch); - lanes = applied.lanes; + const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch }); + tail = applied.tail; + head = applied.head; for (const clientMessageId of applied.acknowledgedClientMessageIds) { acknowledged.add(clientMessageId); } @@ -1326,10 +1155,10 @@ function applyAcceptedTimelinePage(input: { }); } const olderTail = hydrateStreamState( - acceptedUnits.map((unit) => ({ - event: unit.event, - timestamp: unit.timestamp, - timelineCursor: unitPosition(unit, payload.epoch), + acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ + event, + timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, })), { source: "canonical", @@ -1430,6 +1259,7 @@ export function processTimelineResponse( currentCursor, isInitializing, hasActiveInitDeferred, + initRequestDirection, sendingClientMessageIds, } = input; @@ -1444,7 +1274,9 @@ export function processTimelineResponse( cursor: currentCursor, cursorChanged: false, older: "unchanged", - ...processTimelineResponseCompletion(input), + initResolution: hasActiveInitDeferred ? "reject" : null, + clearInitializing: isInitializing, + error: payload.error, sideEffects: [], acknowledgedClientMessageIds: [], }; @@ -1469,18 +1301,23 @@ export function processTimelineResponse( timestamp: new Date(entry.timestamp), })); - const toHydratedEvents = (units: TimelineUnit[]): HydratedTimelineEvent[] => - units.map((unit) => ({ - event: unit.event, - timestamp: unit.timestamp, - timelineCursor: unitPosition(unit, payload.epoch), + const toHydratedEvents = ( + units: TimelineUnit[], + ): Array<{ + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: { epoch: string; seq: number }; + }> => + units.map(({ event, timestamp, seqEnd }) => ({ + event, + timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, })); // ------------------------------------------------------------------ // Derive bootstrap policy (replace vs incremental) // ------------------------------------------------------------------ const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({ - replaceTail: input.replaceTail === true, direction: payload.direction, reset: payload.reset, epoch: payload.epoch, @@ -1535,7 +1372,6 @@ export function processTimelineResponse( : bootstrapPolicy, currentTail, currentHead, - currentCursor, sendingClientMessageIds, preserveContinuity: shouldPreserveReplacementContinuity({ isResumeReplacement, @@ -1574,6 +1410,20 @@ export function processTimelineResponse( // ------------------------------------------------------------------ // Init resolution // ------------------------------------------------------------------ + const shouldResolveDeferredInit = shouldResolveTimelineInit({ + hasActiveInitDeferred, + hasNewer: payload.hasNewer, + isInitializing, + initRequestDirection, + responseDirection: payload.direction, + reset: payload.reset, + }); + const timelineResponseComplete = payload.direction !== "after" || !payload.hasNewer; + const clearInitializing = + (shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred)) && + timelineResponseComplete; + + const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null; const commit = discard ? "discard" : "apply"; return { @@ -1583,7 +1433,9 @@ export function processTimelineResponse( cursor: nextCursor, cursorChanged, older: timelineResult.older, - ...processTimelineResponseCompletion(input), + initResolution, + clearInitializing, + error: null, sideEffects, acknowledgedClientMessageIds: timelineResult.acknowledgedClientMessageIds, }; @@ -1650,7 +1502,13 @@ export interface AgentStreamReducerQueue { } export interface CreateAgentStreamReducerQueueInput { - apply: (agentId: string, events: AgentStreamReducerEvent[]) => void; + getSnapshot: (agentId: string) => AgentStreamReducerSnapshot; + commit: ( + agentId: string, + result: ProcessAgentStreamEventOutput, + events: AgentStreamReducerEvent[], + ) => void; + handleSideEffects: (agentId: string, sideEffects: AgentStreamReducerSideEffect[]) => void; scheduleFlush: (callback: () => void) => number; cancelFlush: (id: number) => void; } @@ -1710,7 +1568,6 @@ function processTimelineSequencingGate(input: { { type: "catch_up", cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq }, - observedSeq: seq, }, ] : [], @@ -1912,7 +1769,15 @@ export function createAgentStreamReducerQueue( cancelScheduledFlush(); } - input.apply(agentId, events); + const result = processAgentStreamEvents({ + events, + ...input.getSnapshot(agentId), + }); + + input.commit(agentId, result, events); + if (result.sideEffects.length > 0) { + input.handleSideEffects(agentId, result.sideEffects); + } }; const flush = () => { @@ -1955,6 +1820,13 @@ export function createAgentStreamReducerQueue( }; } +interface StreamStatePatch { + tail?: StreamItem[]; + head?: StreamItem[]; + acknowledgedClientMessageIds?: readonly string[]; + taskSnapshot?: TodoEntry[]; +} + export function deriveAgentStreamTurnLiveness( events: readonly AgentStreamReducerEvent[], ): TurnLivenessTransition[] { @@ -1976,6 +1848,17 @@ export function deriveAgentStreamTurnLiveness( return transitions; } +export interface CreateSessionAgentStreamReducerQueueInput { + serverId: string; + setAgentStreamState: (serverId: string, agentId: string, state: StreamStatePatch) => void; + setAgentTimelineCursor: ( + serverId: string, + state: (prev: Map) => Map, + ) => void; + recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; + onCommitted?: (agentId: string) => void; +} + interface ScheduledReducerFlush { frameId: number | null; timerId: ReturnType; @@ -1995,7 +1878,7 @@ function clearScheduledReducerFlush(handle: ScheduledReducerFlush): void { // an arbitrary timer that drifts on and off the display beat. A frame callback // never fires in a hidden tab, so a timer races it and wins when nothing is // painting — the store has to keep advancing either way. -export function scheduleAgentStreamReducerFlush(callback: () => void): number { +function scheduleAgentStreamReducerFlush(callback: () => void): number { const id = nextScheduledReducerFlushId; nextScheduledReducerFlushId += 1; @@ -2015,7 +1898,7 @@ export function scheduleAgentStreamReducerFlush(callback: () => void): number { return id; } -export function cancelAgentStreamReducerFlush(id: number) { +function cancelAgentStreamReducerFlush(id: number) { const handle = scheduledReducerFlushes.get(id); if (!handle) { return; @@ -2023,3 +1906,80 @@ export function cancelAgentStreamReducerFlush(id: number) { scheduledReducerFlushes.delete(id); clearScheduledReducerFlush(handle); } + +export function createSessionAgentStreamReducerQueue( + input: CreateSessionAgentStreamReducerQueueInput, +): AgentStreamReducerQueue { + const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap, onCommitted } = + input; + + return createAgentStreamReducerQueue({ + getSnapshot: (agentId) => { + const session = useSessionStore.getState().sessions[serverId]; + const timeline = selectAgentTimelineState(session, agentId); + return { + currentTail: timeline.status === "cold" ? [] : timeline.items, + currentHead: session?.agentStreamHead.get(agentId) ?? [], + currentCursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined, + hasAuthoritativeBaseline: timeline.status === "synced", + isDetached: timeline.status === "synced" && timeline.newer === "available", + }; + }, + commit: (agentId, result, events) => { + if ( + result.changedTail || + result.changedHead || + result.acknowledgedClientMessageIds.length > 0 || + result.taskSnapshot !== undefined + ) { + setAgentStreamState(serverId, agentId, { + ...(result.changedTail ? { tail: result.tail } : {}), + ...(result.changedHead ? { head: result.head } : {}), + ...(result.acknowledgedClientMessageIds.length > 0 + ? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds } + : {}), + ...(result.taskSnapshot !== undefined ? { taskSnapshot: result.taskSnapshot } : {}), + }); + } + if (result.cursorChanged && result.cursor) { + const nextCursor = result.cursor; + const lastEvent = events.at(-1); + setAgentTimelineCursor(serverId, (prev) => { + const current = prev.get(agentId); + if ( + current && + lastEvent && + typeof lastEvent.seq === "number" && + typeof lastEvent.epoch === "string" && + current.epoch === lastEvent.epoch && + lastEvent.seq >= current.startSeq && + lastEvent.seq <= current.endSeq + ) { + return prev; + } + if ( + current && + current.epoch === nextCursor.epoch && + current.startSeq === nextCursor.startSeq && + current.endSeq === nextCursor.endSeq + ) { + return prev; + } + const next = new Map(prev); + next.set(agentId, nextCursor); + return next; + }); + } + onCommitted?.(agentId); + }, + handleSideEffects: (agentId, sideEffects) => { + for (const effect of sideEffects) { + if (effect.type === "catch_up") { + recoverTimelineGap(agentId, effect.cursor); + } + } + }, + scheduleFlush: scheduleAgentStreamReducerFlush, + cancelFlush: cancelAgentStreamReducerFlush, + }); +} diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index e38430ffdb..8c7c156e6f 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -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 { promise: Promise; @@ -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(); readonly cacheRequests: string[] = []; + readonly forcedTimelineTailReplacements = new Set(); cacheGate: Deferred | 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 { 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(); 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 }); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index f1e32c9d3d..d11084cdf2 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -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; + commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void; +} + +async function prepareCachedTimeline(input: { + serverId: string; + agentId: string; + storage: TimelineReplicaStorage; + prepareAgent: (agentId: string) => Promise; +}): Promise { + 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; + 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(); + private readonly preparations = new Map>(); + + constructor( + private readonly serverId: string, + private readonly storage: TimelineReplicaStorage, + private readonly prepareAgent: (agentId: string) => Promise, + ) {} + + async prepare(agentId: string): Promise { + 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 { + 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; +}): 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, +): 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; @@ -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(); + const sources = new Map(); const catchUps = new Map(); const catchUpGenerations = new Map(); - // 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(); + // Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked. + // Acknowledgement and tail completion are the only drain points. + const pendingCatchUps = new Map(); const visibilityCatchUpPending = new Set(); const visibilityCatchUpErrors = new Map(); // 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 => { @@ -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(); diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index a73f52c2d6..cd05fd0826 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -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 = { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 4039ac0d7b..4ebd0972db 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -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; - 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, - 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 { - 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( - 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(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; - timelineCursor?: SourcedPosition; + timelineCursor?: TimelinePosition; } function isRecord(value: unknown): value is Record { @@ -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, - 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 { - if (!timelineCursor) return {}; - return { - timelineCursor: anchorOf(timelineCursor), - source: { - startSeq: timelineCursor.startSeq ?? timelineCursor.seq, - chunks: [{ seq: timelineCursor.seq, offset: 0 }], - }, - }; -} - -function extendTextRow( - 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, 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, - 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, ): 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((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; diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 72158779a3..c8e2479894 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -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, diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index aff01550bf..d700a0f510 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -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;