From 1c51b52d079977aea4c9c2b20243d30be112f5f1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 15:52:21 +0200 Subject: [PATCH] Restore cached conversations before reconnecting (#4421) * fix(app): restore cached conversations before reconnecting Give each host one timeline write authority so cache restores, live events and fetched pages publish consistent rows and coverage. Persist a moving 40-entry restart page independently of loaded scrollback, and keep native navigation mounted while startup settings hydrate. * fix(app): recover cache writes after initialization failures Keep accepted writes pending and retry initialization without another UI update. Publish the storage index only after loading succeeds, while queued host identity changes still settle in order. * test(app): wait for sidebar and workspace transitions Viewport intersection and leaving the creation route can precede completed UI transitions. Wait for the layering hit test and selected agent tab before asserting or navigating again. * fix(app): restore provider loading on native clients The native AbortSignal lacks throwIfAborted, so successful provider responses failed before reaching every model selector. Preserve both cancellation boundaries using the supported aborted property and the existing query cancellation error. --- docs/architecture.md | 23 +- docs/data-model.md | 18 +- docs/expo-router.md | 13 +- docs/mobile-panels.md | 4 +- docs/timeline-sync.md | 110 +- docs/unistyles.md | 3 +- .../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 + packages/app/src/app/_layout.tsx | 88 +- .../src/app/h/[serverId]/agent/[agentId].tsx | 6 - .../appearance-style-boundary.test.ts | 57 + .../components/appearance-style-boundary.tsx | 9 + .../compact-explorer-sidebar-host.tsx | 10 +- .../components/ui/menu/menu-anchor.test.ts | 20 +- .../app/src/components/ui/menu/menu-anchor.ts | 27 +- .../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 | 71 +- packages/app/src/contexts/session-context.tsx | 60 +- .../app/src/data/providers-snapshot.test.ts | 247 ++ packages/app/src/data/providers-snapshot.ts | 6 +- .../hooks/use-agent-initialization.test.ts | 20 +- packages/app/src/navigation/themed-stack.tsx | 33 +- .../workspace-route-navigation.test.ts | 101 +- .../navigation/workspace-route-navigation.ts | 32 +- .../directory-sync/agent-replica.test.ts | 14 + .../runtime/directory-sync/agent-replica.ts | 70 +- .../src/runtime/directory-sync/index.test.ts | 741 ++++-- .../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 | 98 +- .../src/runtime/replica-cache/index.test.ts | 548 ++++- .../app/src/runtime/replica-cache/index.ts | 865 +++---- .../runtime/replica-cache/row-store-sqlite.ts | 17 +- .../replica-cache/row-store.contract.ts | 23 +- .../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 | 698 +++++- packages/app/src/timeline/replica.ts | 752 ++++++ .../timeline/session-stream-reducers.test.ts | 856 ++++++- .../src/timeline/session-stream-reducers.ts | 924 +++---- .../src/timeline/viewed-timeline-sync.test.ts | 327 ++- .../app/src/timeline/viewed-timeline-sync.ts | 637 ++--- 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 + 66 files changed, 9198 insertions(+), 3252 deletions(-) create mode 100644 packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json create mode 100644 packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json delete mode 100644 packages/app/src/timeline/fetch-agent-timeline-once.test.ts delete mode 100644 packages/app/src/timeline/fetch-agent-timeline-once.ts create mode 100644 packages/app/src/timeline/replica.contract.test.ts create mode 100644 packages/app/src/timeline/replica.ts diff --git a/docs/architecture.md b/docs/architecture.md index 134e8f3532..e83e3d0539 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,21 +113,14 @@ 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 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. +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). 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 5bd96f5252..7e05a6d1de 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -578,16 +578,20 @@ 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. 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. +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. 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. +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. -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 +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 source code, prompts, and tool output; encrypted-at-rest storage is a separate security decision. ### Draft Store diff --git a/docs/expo-router.md b/docs/expo-router.md index d6eaf00374..4ccdc14d39 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -64,7 +64,10 @@ root host route and pass the nested workspace screen when a host route is already mounted, or Expo Router can append extra hidden workspace deck entries. The workspace navigation helper inspects the mounted navigation state to make that decision; if no host route is mounted yet, it falls back to ordinary route -navigation. +navigation. Both paths wait for the root navigation container to be ready. The +workspace navigation owner keeps the latest pending intent across ref +re-registration and applies it on the container's `ready` event; routes must not +add their own readiness retries. Those hidden entries are not harmless: composer floating panels can measure against the wrong deck and disappear offscreen. Follow the @@ -144,6 +147,14 @@ Do not read the active theme with `useUnistyles()` in a layout to build a small `withUnistyles` boundary without subscribing the route tree itself to every Unistyles runtime update. +Navigators keep their identity across appearance changes. `ThemedStack` remounts +each screen's content below the native stack when appearance tokens change; a +keyed wrapper above a stack remounts the stack itself, and Android crashes when +that happens while a FragmentManager transaction is running, which settings +hydration at startup makes likely. A screen that mounts a nested navigator (the +root stack's `h/[serverId]`) is passed in `nestedNavigatorScreens` so the nested +stack owns its own screens. See [unistyles.md](unistyles.md). + ## Regression Shape Pure helper tests are useful but not enough. The failure mode here is native diff --git a/docs/mobile-panels.md b/docs/mobile-panels.md index 447b7108e4..2e6e2f1605 100644 --- a/docs/mobile-panels.md +++ b/docs/mobile-panels.md @@ -33,7 +33,9 @@ The UI worklet owns transient motion: React publishes the active panel only when the canonical target and the UI-thread position agree at the final anchor. Retained content never observes gesture previews, progress, or an unsettled target. The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while -React owns pointer events and accessibility. +React owns pointer events and accessibility. Native panel hosts and their dependent draggable lists +also retain identity across appearance hydration and settings changes. Do not wrap those hosts in +appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences). ## Why one position diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 5d3464a30b..caf7dea383 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -36,11 +36,24 @@ 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"`. If the daemon -responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap -recovery is complete only when `hasNewer: false`. +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. 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. @@ -82,7 +95,9 @@ 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. + 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. 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 @@ -90,39 +105,51 @@ history start; a response requested from a pre-replacement range is stale and is ## Client replica lifetime -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. +`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. -Removing the host from the registry is the destructive boundary: it stops the runtime and clears the -session and host-scoped setup state together. +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. -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. +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 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. +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). -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. +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. -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. +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. ## Selective and legacy delivery @@ -144,12 +171,18 @@ version. ## Projected pages reconcile with live presentation -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. +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. 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 @@ -230,6 +263,7 @@ 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/docs/unistyles.md b/docs/unistyles.md index af864cf000..3daae333df 100644 --- a/docs/unistyles.md +++ b/docs/unistyles.md @@ -346,7 +346,8 @@ Gotchas: - **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`). - **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes. - **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry. -- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries. +- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping. +- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator. - **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`. ## Patching The Web Runtime diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 194fe43d1d..ab8a2f85bf 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 { - expectResumeOverflowFallsBackToOneTail, + expectEmptyBaselineBootstrapsWithOneTail, rememberTimelineRequestCounts, } from "../support/helpers/timeline-resume"; import { @@ -484,7 +484,8 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction( await expect(promptRow).toBeVisible(); await expect(response).toBeVisible(); await expectRenderedBefore(promptRow, response); - expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn); + // The initial timeline was empty and live rows were suppressed: there is no resume cursor. + expectEmptyBaselineBootstrapsWithOneTail(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 4119f7c692..9becf359df 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -94,14 +94,18 @@ interface PopoverFrameRecorderWindow extends Window { __stopComposerAutocompleteFrameRecorder?: () => void; } -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 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 installListCommandsStub(page: Page): Promise { @@ -676,13 +680,11 @@ test.describe("Composer autocomplete", () => { const popoverBox = await popover.boundingBox(); expect(popoverBox).not.toBeNull(); - const topTestId = await getTopTestIdAtPoint( + await expectAutocompleteCoveredAtPoint( 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 f2ee89c12f..77e2a80998 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 } from "../support/fixtures"; +import { test, expect, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; import { @@ -30,8 +30,9 @@ const PROMPT_PROFILE: TerminalProfile = { args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"], }; -function hasLeftNewWorkspaceRoute(url: URL): boolean { - return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`); +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 }); } test.describe("New workspace: launch target memory", () => { @@ -78,10 +79,8 @@ test.describe("New workspace: launch target memory", () => { await selectChatLaunch(page); await expectChatLaunchSelected(page); await submitNewWorkspacePrompt(page, "hello from chat"); - // 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 }); + // Leaving /new can expose a previous workspace before the submitted draft opens. + await expectChatLaunchComplete(page); 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 33e6ff8ddc..0c7c84b39b 100644 --- a/packages/app/e2e/browser/sidebar-model-b.spec.ts +++ b/packages/app/e2e/browser/sidebar-model-b.spec.ts @@ -96,8 +96,9 @@ 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); - const agentTabs = await getVisibleWorkspaceAgentTabIds(page); - expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`); + await expect + .poll(() => getVisibleWorkspaceAgentTabIds(page)) + .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 91b6867cf0..11cc4bc3c3 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -494,18 +494,19 @@ export async function holdAgentOlderTimelinePages( }; } -export async function delayAgentBootstrapTailResponse( +export async function delayAgentBootstrapTimelineResponse( page: Page, agentId: string, ): Promise { - let tailReleased = false; + let bootstrapReleased = false; + let bootstrapResponseSeen = false; let catchUpReleased = false; - const delayedTailForwards: Array<() => void> = []; + const delayedBootstrapForwards: Array<() => void> = []; const delayedCatchUpForwards: Array<() => void> = []; - let resolveDelayedTail: (() => void) | null = null; + let resolveDelayedBootstrap: (() => void) | null = null; let resolveDelayedCatchUp: (() => void) | null = null; - const delayedTail = new Promise((resolve) => { - resolveDelayedTail = resolve; + const delayedBootstrap = new Promise((resolve) => { + resolveDelayedBootstrap = resolve; }); const delayedCatchUp = new Promise((resolve) => { resolveDelayedCatchUp = resolve; @@ -519,10 +520,16 @@ export async function delayAgentBootstrapTailResponse( const payload = sessionMessage ? getPayload(sessionMessage) : null; const isTimelineResponse = sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId; - if (isTimelineResponse && payload.direction === "tail") { - resolveDelayedTail?.(); - if (tailReleased) ws.send(message); - else delayedTailForwards.push(() => ws.send(message)); + // 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)); return; } if (isTimelineResponse && payload.direction === "after") { @@ -537,14 +544,14 @@ export async function delayAgentBootstrapTailResponse( return { release() { - tailReleased = true; - for (const forward of delayedTailForwards.splice(0)) forward(); + bootstrapReleased = true; + for (const forward of delayedBootstrapForwards.splice(0)) forward(); }, releaseCatchUp() { catchUpReleased = true; for (const forward of delayedCatchUpForwards.splice(0)) forward(); }, - waitForDelayedResponse: () => delayedTail, + waitForDelayedResponse: () => delayedBootstrap, waitForDelayedCatchUp: () => delayedCatchUp, }; } diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index 0cdd34857c..c6afe03736 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 { - delayAgentBootstrapTailResponse, + delayAgentBootstrapTimelineResponse, delayAgentOlderTimelineResponse, holdDaemonHydration, holdAgentOlderTimelinePages, @@ -296,9 +296,10 @@ export async function reloadAgentTimelineFromPersistedReplica( const timeline = cache?.hosts ?.flatMap((host) => host.timelines) .find((candidate) => candidate.agentId === agent.agentId); - return timeline?.items?.length === 50; + // This long fixture fills the latest 40-entry restart page, even after loading scrollback. + return timeline?.items?.length; }) - .toBe(true); + .toBe(40); await page.reload(); await expectTimelinePromptVisible(page, agent.newestPrompt); @@ -503,7 +504,7 @@ export async function holdBootstrapTimelinePage( page: Page, agent: LongTimelineAgent, ): Promise { - return delayAgentBootstrapTailResponse(page, agent.agentId); + return delayAgentBootstrapTimelineResponse(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 73c921776c..e397d17325 100644 --- a/packages/app/e2e/support/helpers/timeline-resume.ts +++ b/packages/app/e2e/support/helpers/timeline-resume.ts @@ -48,6 +48,14 @@ 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/_layout.tsx b/packages/app/src/app/_layout.tsx index f2b0755122..d0090178a0 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -28,12 +28,12 @@ import { CommandCenterProvider } from "@/command-center/provider"; import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration"; import { PluginCommandCenterActions } from "@/plugins/command-center/registration"; import { AddProjectFlowHost } from "@/components/add-project-flow-host"; -import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source"; import { DownloadToast } from "@/components/download-toast"; import { QuittingOverlay } from "@/components/quitting-overlay"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { AppDiagnosticHost } from "@/components/app-diagnostic-host"; +import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { LeftSidebar } from "@/components/left-sidebar"; import { WindowSidebarMenuToggle } from "@/components/headers/menu-header"; import { DesktopWindowControls } from "@/components/desktop/window-controls"; @@ -551,11 +551,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon keyboardShortcutsEnabled={keyboardShortcutsEnabled} /> ); + let themedSidebarChrome = sidebarChrome; + if (isWeb) { + themedSidebarChrome = {sidebarChrome}; + } const workspaceChrome = ( {!isCompactLayout ? ( - {sidebarChrome} + {themedSidebarChrome} ) : null} {usesCompactExplorerHost ? ( @@ -575,42 +579,48 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon ); + // Native panel gesture hosts outlive appearance keys, like native navigators. + // Their tracked styles update in place; web numeric styles still need remounting. const surface = ( {workspaceChrome} - {!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? ( - - - - - - ) : null} - - - {isCompactLayout ? sidebarChrome : null} - - - - - - - - - - - - - - - - - - + + {!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? ( + + + + + + ) : null} + + + + {isCompactLayout ? themedSidebarChrome : null} + + + + + + + + + + + + + + + + + + + + ); @@ -672,7 +682,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { - {children} + {children} ); @@ -883,11 +893,15 @@ const ROOT_STACK_SCREEN_OPTIONS = { headerShown: false, animation: "none" as const, }; +const ROOT_STACK_NESTED_NAVIGATOR_SCREENS = ["h/[serverId]"] as const; function RootStack() { const storeReady = useStoreReady(); return ( - + diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 7f66a07b25..0ad0394143 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -38,12 +38,6 @@ 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/components/appearance-style-boundary.test.ts b/packages/app/src/components/appearance-style-boundary.test.ts index a92278cce5..da6fb85c45 100644 --- a/packages/app/src/components/appearance-style-boundary.test.ts +++ b/packages/app/src/components/appearance-style-boundary.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import ts from "typescript"; import { describe, expect, it } from "vitest"; import { darkTheme } from "@/styles/theme"; import { appearanceStyleBoundaryKey } from "./appearance-style-boundary"; @@ -14,3 +16,58 @@ describe("appearanceStyleBoundaryKey", () => { ); }); }); + +// This is a placement contract against the production JSX, not a simulated native mount. +// Native Gesture Handler/Fabric lifetime behavior is verified on the release app. +describe("retained native gesture host appearance placement", () => { + it.each([ + ["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"]], + [ + "./compact-explorer-sidebar-host.tsx", + ["explorer", "themedExplorer", "CompactExplorerSidebar", "NativeExplorerSidebarDock"], + ], + ] as const)("keeps native hosts outside appearance keys in %s", (path, hosts) => { + const source = ts.createSourceFile( + path, + readFileSync(new URL(path, import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + let hostsFound = 0; + let webKeysFound = 0; + function visit(node: ts.Node) { + let name: string | undefined; + if (ts.isIdentifier(node)) name = node.text; + if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source); + if (name && hosts.some((host) => host === name)) { + hostsFound++; + let webOnly = false; + let keyed = false; + for ( + let child = node, parent = node.parent; + parent; + child = parent, parent = parent.parent + ) { + if ( + ts.isIfStatement(parent) && + parent.expression.getText(source) === "isWeb" && + parent.thenStatement === child + ) + webOnly = true; + if ( + ts.isJsxElement(parent) && + parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary" + ) + keyed = true; + } + if (keyed && webOnly) webKeysFound++; + expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe(false); + } + ts.forEachChild(node, visit); + } + visit(source); + expect(hostsFound).toBeGreaterThan(0); + expect(webKeysFound).toBeGreaterThan(0); + }); +}); diff --git a/packages/app/src/components/appearance-style-boundary.tsx b/packages/app/src/components/appearance-style-boundary.tsx index f64b3e553f..e4f2a61f73 100644 --- a/packages/app/src/components/appearance-style-boundary.tsx +++ b/packages/app/src/components/appearance-style-boundary.tsx @@ -7,6 +7,15 @@ interface AppearanceStyleBoundaryProps { children: ReactNode; } +// Remounts its children when an appearance token changes. Memoized and parsed content bakes +// tokens in, and on web numeric tokens (font sizes, line heights) are baked into generated +// classes that only a re-render refreshes. Native tracked styles update in place. +// Keep native gesture hosts outside these keys too: shared gesture refs can outlive detached +// dependent views until passive cleanup. It must sit below every native navigator: remounting +// a navigator while settings hydrate +// detaches its screen container inside a FragmentManager transaction and crashes Android. +// `ThemedStack` places one per screen; the app shell places them around the chrome outside the +// navigators. function AppearanceStyleBoundaryBase({ appearanceKey, children }: AppearanceStyleBoundaryProps) { return {children}; } diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx index 3a05f1c265..2dae4b9042 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host.tsx +++ b/packages/app/src/components/compact-explorer-sidebar-host.tsx @@ -21,6 +21,7 @@ import { resolveCompactExplorerSidebarHostModel, type CompactExplorerSidebarHostModel, } from "@/components/compact-explorer-sidebar-host-state"; +import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; interface CompactExplorerOpenGestureSurfaceProps { children: ReactNode; @@ -177,11 +178,16 @@ export function CompactExplorerSidebarHost({ ) : null; + let themedExplorer = explorer; + if (isWeb) { + themedExplorer = {explorer}; + } + if (presentation === "dock") { return ( {children} - {explorer} + {themedExplorer} ); } @@ -194,7 +200,7 @@ export function CompactExplorerSidebarHost({ > {children} - {explorer} + {themedExplorer} ); } diff --git a/packages/app/src/components/ui/menu/menu-anchor.test.ts b/packages/app/src/components/ui/menu/menu-anchor.test.ts index ecdf438fe7..add33fff3a 100644 --- a/packages/app/src/components/ui/menu/menu-anchor.test.ts +++ b/packages/app/src/components/ui/menu/menu-anchor.test.ts @@ -68,10 +68,20 @@ describe("computePosition", () => { describe("getTransformOrigin", () => { it.each([ - ["bottom", "start", "left top"], - ["top", "end", "right bottom"], - ["right", "center", "center center"], - ] as const)("maps %s/%s to %s", (placement, alignment, expected) => { - expect(getTransformOrigin(placement, alignment)).toBe(expected); + ["bottom", "start", [0, 0, 0]], + ["bottom", "center", ["50%", 0, 0]], + ["bottom", "end", ["100%", 0, 0]], + ["top", "start", [0, "100%", 0]], + ["top", "center", ["50%", "100%", 0]], + ["top", "end", ["100%", "100%", 0]], + ["left", "start", [0, "50%", 0]], + ["left", "center", ["50%", "50%", 0]], + ["left", "end", ["100%", "50%", 0]], + ["right", "start", [0, "50%", 0]], + ["right", "center", ["50%", "50%", 0]], + ["right", "end", ["100%", "50%", 0]], + ] as const)("emits a native-ready origin for %s/%s", (placement, alignment, expected) => { + // Theme updates may reach Fabric without React Native's JS string preprocessing. + expect(getTransformOrigin(placement, alignment)).toEqual(expected); }); }); diff --git a/packages/app/src/components/ui/menu/menu-anchor.ts b/packages/app/src/components/ui/menu/menu-anchor.ts index c7548dde68..efdfbc8ab2 100644 --- a/packages/app/src/components/ui/menu/menu-anchor.ts +++ b/packages/app/src/components/ui/menu/menu-anchor.ts @@ -144,18 +144,21 @@ export function computePosition({ return { ...clampToDisplayArea({ ...anchored, contentSize, displayArea }), actualPlacement }; } -export function getTransformOrigin(placement: Placement, alignment: Alignment): string { - let vertical: string; - if (placement === "bottom") vertical = "top"; - else if (placement === "top") vertical = "bottom"; - else vertical = "center"; +export function getTransformOrigin( + placement: Placement, + alignment: Alignment, +): [number | string, number | string, number] { + let vertical: number | string; + if (placement === "bottom") vertical = 0; + else if (placement === "top") vertical = "100%"; + else vertical = "50%"; - let horizontal: string; - if (alignment === "start") horizontal = "left"; - else if (alignment === "end") horizontal = "right"; - else horizontal = "center"; + let horizontal: number | string; + if (alignment === "start") horizontal = 0; + else if (alignment === "end") horizontal = "100%"; + else horizontal = "50%"; - // React Native parses transform-origin positionally (x then y), unlike CSS - // which accepts keyword pairs in either order. - return `${horizontal} ${vertical}`; + // Native theme updates can bypass RN's JS string preprocessing. Supply the native + // array shape directly; Reanimated's web style builder also accepts this form. + return [horizontal, vertical, 0]; } diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 8587fa3db6..929b3e1be0 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -2,17 +2,34 @@ * @vitest-environment jsdom */ import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, 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 d1cfce1161..8f708744c0 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 { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { createUserMessage, @@ -214,17 +214,18 @@ export function useDraftAgentCreateFlow({ if (createResult.agentId) { updatePendingAgentId({ draftId, agentId: createResult.agentId }); - handoffCreatedAgentMessageSubmission( - pendingServerId, - createResult.agentId, - createUserMessage({ - clientMessageId: attempt.clientMessageId, - text: attempt.text, - timestamp: attempt.timestamp, - images: attempt.images, - attachments: attempt.attachments, - }), - ); + getHostRuntimeStore() + .getTimelineReplica(pendingServerId) + .handoffSubmission( + 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 84de49a3b9..a1c97957aa 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1484,7 +1484,10 @@ function ComposerContentImpl({ supportsForgeAttachments: supportsForgeSearch, }), encodeImages, - submission: createMessageSubmissionWriter(serverId), + submission: createMessageSubmissionWriter( + serverId, + getHostRuntimeStore().getTimelineReplica(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 3ee174d554..1c76186615 100644 --- a/packages/app/src/composer/submission/writer.test.ts +++ b/packages/app/src/composer/submission/writer.test.ts @@ -1,3 +1,4 @@ +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"; @@ -40,10 +41,17 @@ afterEach(() => { test("tracks submissions when canonical prompts are supported", () => { initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: true }); - createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID).begin( - "agent-1", - submittedMessage("canonical-only-message"), - ); + 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")); 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 f8b40034b6..5cef427fae 100644 --- a/packages/app/src/composer/submission/writer.ts +++ b/packages/app/src/composer/submission/writer.ts @@ -1,72 +1,19 @@ import type { MessageSubmissionWriter } from "@/composer/actions"; import { useSessionStore } from "@/stores/session-store"; -import { - appendSubmittedUserMessage, - removeSubmittedUserMessage, - type UserMessageItem, -} from "@/types/stream"; +import type { TimelineReplica } from "@/timeline/replica"; -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( +export function createMessageSubmissionWriter( serverId: string, - 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) => appendUntrackedSubmission(serverId, agentId, message), - accept: () => undefined, - reject: (agentId, clientMessageId) => { - removeUntrackedSubmission(serverId, agentId, clientMessageId); - return "rejected"; - }, - }; -} - -export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { - const supportsTrackedMessageSubmissions = + 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; - 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), + begin: (agentId, message) => replica.beginSubmission(agentId, message, tracked), + accept: (agentId, clientMessageId) => replica.acceptSubmission(agentId, clientMessageId), reject: (agentId, clientMessageId) => - useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId), + replica.rejectSubmission(agentId, clientMessageId, tracked), }; } - -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 fff3a0f496..d484169734 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -15,9 +15,7 @@ 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"; @@ -229,16 +227,12 @@ 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 @@ -251,7 +245,6 @@ 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()); @@ -417,33 +410,15 @@ 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().createViewedTimelineOwner(serverId, { + const sync = getHostRuntimeStore().getViewedTimelineOwner(serverId); + const detach = sync.attachTransport({ 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); @@ -474,18 +449,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider throw error; } }, - fetchLatestTail: async (agentId) => { - forcedTimelineTailReplacements.current.add(agentId); - try { - return await getHostRuntimeStore().fetchAgentTimeline( - serverId, - agentId, - planTimelineTailFetch(), - ); - } finally { - forcedTimelineTailReplacements.current.delete(agentId); - } - }, + fetchLatestTail: (agentId) => + getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch(), true), reportError: (error) => { console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); }, @@ -495,17 +460,15 @@ 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; } - setViewedTimelineSync(serverId, null); - sync.dispose(); + detach(); }; - }, [client, serverId, setInitializingAgents, setViewedTimelineSync]); + }, [client, serverId, setInitializingAgents]); useEffect(() => { viewedTimelineSyncRef.current?.setConnected(isConnected); @@ -553,12 +516,6 @@ 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( @@ -764,7 +721,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return () => { unsubTimelineReplacement(); unsubAgentStream(); - unsubAgentTimeline(); unsubProviderSubagentUpdate(); unsubAgentAttention(); unsubCheckoutStatusUpdate(); @@ -784,15 +740,11 @@ 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/data/providers-snapshot.test.ts b/packages/app/src/data/providers-snapshot.test.ts index 1e62958440..c1a8ca1ec2 100644 --- a/packages/app/src/data/providers-snapshot.test.ts +++ b/packages/app/src/data/providers-snapshot.test.ts @@ -1,5 +1,16 @@ +import { createRequire } from "node:module"; +import { QueryClient, CancelledError } from "@tanstack/react-query"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages"; +import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; +import { createProviderSnapshotCache } from "./provider-snapshot-cache"; +import { + resolveProviderIconName, + replaceProviderSnapshotIcons, +} from "@/components/provider-icon-name"; import { describe, expect, it } from "vitest"; import { + fetchProvidersSnapshot, isProvidersSnapshotHomeScope, normalizeProvidersSnapshotCwd, providersSnapshotQueryKey, @@ -49,3 +60,239 @@ describe("providers snapshot query scope", () => { ); }); }); + +// Resolve the exact CommonJS implementation installed by React Native's setUpXHR. +// Its runtime signal deliberately lacks the newer methods in the DOM typings. +const nativeRequire = createRequire(require.resolve("react-native/package.json")); +const { AbortController: NativeAbortController }: { AbortController: typeof AbortController } = + nativeRequire("abort-controller/dist/abort-controller"); + +function createStorage(onWrite: () => void = () => {}) { + const values = new Map(); + return { + async getItem(key: string) { + return values.get(key) ?? null; + }, + async setItem(key: string, value: string) { + values.set(key, value); + onWrite(); + }, + async removeItem(key: string) { + values.delete(key); + }, + async getAllKeys() { + return [...values.keys()]; + }, + async multiGet(keys: readonly string[]) { + return keys.map((key) => [key, values.get(key) ?? null] as const); + }, + async multiRemove(keys: readonly string[]) { + for (const key of keys) values.delete(key); + }, + }; +} + +const entries: ProviderSnapshotEntry[] = [ + { + provider: "test-provider", + status: "ready", + enabled: true, + iconSvg: '', + models: [ + { provider: "test-provider", id: "test-model", label: "Test model", thinkingOptions: [] }, + ], + }, +]; +const snapshot = { + requestId: "test-request", + entries: [], + cwd: "/repo", + generatedAt: "2026-09-07T00:00:00.000Z", + snapshotHash: "test-hash", + compactSnapshot: compactProviderSnapshot(entries), +} satisfies GetProvidersSnapshotResponseMessage["payload"]; + +describe("provider fetch with runtime abort signals", () => { + it.each([ + ["React Native", NativeAbortController], + ["default", AbortController], + ])( + "publishes ready models and persists the real compact snapshot with %s", + async (_, Controller) => { + const controller = new Controller(); + const cache = createProviderSnapshotCache(createStorage()); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const serverId = "successful-provider-fetch"; + const queryKey = providersSnapshotQueryKey(serverId, snapshot.cwd); + try { + const result = await queryClient.fetchQuery({ + queryKey, + queryFn: () => + fetchProvidersSnapshot({ + client: { getProvidersSnapshot: async () => snapshot }, + serverId, + cwd: snapshot.cwd, + queryClient, + cache, + signal: controller.signal, + }), + }); + expect(result.entries).toEqual(entries); + expect(queryClient.getQueryData(queryKey)).toEqual(result); + expect((await cache.read(serverId, snapshot.cwd))?.entries).toEqual(entries); + expect(resolveProviderIconName("test-provider", serverId)).toEqual({ + kind: "svg", + svg: entries[0].iconSvg, + }); + } finally { + queryClient.clear(); + replaceProviderSnapshotIcons(serverId, []); + } + }, + ); +}); + +describe.each([ + ["React Native", NativeAbortController], + ["default", AbortController], +])("provider fetch cancellation with %s", (_, Controller) => { + it.each([ + { boundary: "response", abortOnWrite: 0, hasBody: false, hasReference: false }, + { boundary: "body persistence", abortOnWrite: 1, hasBody: true, hasReference: false }, + { boundary: "directory persistence", abortOnWrite: 2, hasBody: true, hasReference: true }, + ])( + "does not publish after cancellation at $boundary", + async ({ abortOnWrite, hasBody, hasReference }) => { + const controller = new Controller(); + let writes = 0; + const storage = createStorage(() => { + writes += 1; + if (writes === abortOnWrite) controller.abort(); + }); + const cache = createProviderSnapshotCache(storage); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const serverId = "cancelled-provider-fetch"; + const queryKey = providersSnapshotQueryKey(serverId, snapshot.cwd); + const previous = { ...snapshot, entries: [{ ...entries[0], iconSvg: "previous-icon" }] }; + queryClient.setQueryData(queryKey, previous); + replaceProviderSnapshotIcons(serverId, previous.entries); + try { + await expect( + queryClient.fetchQuery({ + queryKey, + queryFn: () => + fetchProvidersSnapshot({ + client: { + getProvidersSnapshot: async () => { + if (abortOnWrite === 0) controller.abort(); + return snapshot; + }, + }, + serverId, + cwd: snapshot.cwd, + queryClient, + cache, + signal: controller.signal, + }), + }), + ).rejects.toBeInstanceOf(CancelledError); + expect(controller.signal.aborted).toBe(true); + expect(queryClient.getQueryData(queryKey)).toEqual(previous); + expect(resolveProviderIconName("test-provider", serverId)).toEqual({ + kind: "svg", + svg: "previous-icon", + }); + expect((await cache.readHash(serverId, "test-hash"))?.entries ?? null).toEqual( + hasBody ? entries : null, + ); + expect((await cache.read(serverId, snapshot.cwd))?.entries ?? null).toEqual( + hasReference ? entries : null, + ); + } finally { + queryClient.clear(); + replaceProviderSnapshotIcons(serverId, []); + } + }, + ); +}); + +it("keeps a replacement query when the cancelled native fetch finishes late", async () => { + const controller = new NativeAbortController(); + const cache = createProviderSnapshotCache(createStorage()); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const serverId = "superseded-provider-fetch"; + const queryKey = providersSnapshotQueryKey(serverId, snapshot.cwd); + let finishResponse = () => {}; + const responseReady = new Promise((resolve) => { + finishResponse = resolve; + }); + let markStarted = () => {}; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + let oldFetch: Promise | undefined; + try { + const oldQuery = queryClient.fetchQuery({ + queryKey, + queryFn: ({ signal }) => { + signal.addEventListener("abort", () => controller.abort(), { once: true }); + oldFetch = fetchProvidersSnapshot({ + client: { + getProvidersSnapshot: async () => { + markStarted(); + await responseReady; + return snapshot; + }, + }, + serverId, + cwd: snapshot.cwd, + queryClient, + cache, + signal: controller.signal, + }); + return oldFetch; + }, + }); + const cancelledQuery = expect(oldQuery).rejects.toBeInstanceOf(CancelledError); + await started; + await queryClient.cancelQueries({ queryKey, exact: true }); + await cancelledQuery; + expect(controller.signal.aborted).toBe(true); + const replacementEntries = [{ ...entries[0], iconSvg: "replacement-icon", models: [] }]; + const replacement = await queryClient.fetchQuery({ + queryKey, + queryFn: ({ signal }) => + fetchProvidersSnapshot({ + client: { + getProvidersSnapshot: async () => ({ + ...snapshot, + snapshotHash: "replacement-hash", + compactSnapshot: compactProviderSnapshot(replacementEntries), + }), + }, + serverId, + cwd: snapshot.cwd, + queryClient, + cache, + signal, + }), + }); + finishResponse(); + await expect(oldFetch).rejects.toBeInstanceOf(CancelledError); + expect(queryClient.getQueryData(queryKey)).toEqual(replacement); + expect(replacement.entries).toEqual(replacementEntries); + expect((await cache.read(serverId, snapshot.cwd))?.entries).toEqual(replacementEntries); + expect(resolveProviderIconName("test-provider", serverId)).toEqual({ + kind: "svg", + svg: "replacement-icon", + }); + } finally { + finishResponse(); + queryClient.clear(); + replaceProviderSnapshotIcons(serverId, []); + } +}); diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 868756ee46..0e9cc26495 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -1,4 +1,4 @@ -import type { QueryClient } from "@tanstack/react-query"; +import { CancelledError, type QueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages"; import { @@ -100,7 +100,7 @@ export async function fetchProvidersSnapshot(input: { } } } - input.signal?.throwIfAborted(); + if (input.signal?.aborted) throw new CancelledError(); snapshot = await cache.materialize(input.serverId, snapshot); if (snapshot.compactSnapshot && snapshot.snapshotHash) { await cache.write({ @@ -113,7 +113,7 @@ export async function fetchProvidersSnapshot(input: { signal: input.signal, }); } - input.signal?.throwIfAborted(); + if (input.signal?.aborted) throw new CancelledError(); replaceProviderSnapshotIcons(input.serverId, snapshot.entries); return snapshot; } diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index f2726fba02..899f95c631 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -53,13 +53,15 @@ describe("ensureAgentIsInitialized", () => { const client = new FakeDaemonClient(); const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); - useSessionStore - .getState() - .setAgentTimelineCursor( - serverId, - new Map([[agentId, { epoch: "epoch-1", startSeq: 1, endSeq: 42 }]]), - ); - useSessionStore.getState().setAgentAuthoritativeHistoryApplied(serverId, agentId, true); + useSessionStore.getState().applyAgentTimelineResponseState(serverId, agentId, { + items: [], + head: [], + range: { epoch: "epoch-1", startSeq: 1, endSeq: 42 }, + older: "none", + newer: false, + synchronized: true, + submissions: [], + }); void ensureAgentIsInitialized({ serverId, @@ -129,7 +131,7 @@ describe("ensureAgentIsInitialized", () => { older: "none", newer: false, synchronized: false, - acknowledgedClientMessageIds: [], + submissions: [], }); void ensureAgentIsInitialized({ @@ -172,7 +174,7 @@ describe("ensureAgentIsInitialized", () => { older: "available", newer: false, synchronized: true, - acknowledgedClientMessageIds: [], + submissions: [], }); void ensureAgentIsInitialized({ diff --git a/packages/app/src/navigation/themed-stack.tsx b/packages/app/src/navigation/themed-stack.tsx index cf83f701c9..010877c061 100644 --- a/packages/app/src/navigation/themed-stack.tsx +++ b/packages/app/src/navigation/themed-stack.tsx @@ -1,15 +1,29 @@ import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { Stack } from "expo-router"; -import { type ReactNode, useMemo } from "react"; +import { isValidElement, type ReactElement, type ReactNode, useCallback, useMemo } from "react"; import { withUnistyles } from "react-native-unistyles"; +import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; interface ThemedStackBaseProps { backgroundColor: string; children?: ReactNode; screenOptions?: NativeStackNavigationOptions; + // Screens whose content is another navigator. That navigator owns its own screens' appearance + // boundary; wrapping it here would remount it. + nestedNavigatorScreens?: readonly string[]; } -function ThemedStackBase({ backgroundColor, children, screenOptions }: ThemedStackBaseProps) { +interface ScreenContent { + route: { name: string }; + children: ReactNode; +} + +function ThemedStackBase({ + backgroundColor, + children, + screenOptions, + nestedNavigatorScreens, +}: ThemedStackBaseProps) { const themedScreenOptions = useMemo( () => ({ ...screenOptions, @@ -17,8 +31,21 @@ function ThemedStackBase({ backgroundColor, children, screenOptions }: ThemedSta }), [backgroundColor, screenOptions], ); + // Appearance changes remount each screen's content below the native stack, so the navigator + // and its screen containers keep their identity. See docs/unistyles.md. + const screenLayout = useCallback( + ({ route, children: content }: ScreenContent): ReactElement => { + if (nestedNavigatorScreens?.includes(route.name) && isValidElement(content)) return content; + return {content}; + }, + [nestedNavigatorScreens], + ); - return {children}; + return ( + + {children} + + ); } export const ThemedStack = withUnistyles(ThemedStackBase, (theme) => ({ diff --git a/packages/app/src/navigation/workspace-route-navigation.test.ts b/packages/app/src/navigation/workspace-route-navigation.test.ts index 704345f9a0..a331451ed5 100644 --- a/packages/app/src/navigation/workspace-route-navigation.test.ts +++ b/packages/app/src/navigation/workspace-route-navigation.test.ts @@ -7,23 +7,36 @@ import { function createNavigationRef(rootState: unknown, options: { ready?: boolean } = {}) { const dispatch = vi.fn(); - const navigationRef = { - current: { - isReady: () => options.ready ?? true, - getRootState: () => rootState, - dispatch, + let ready = options.ready ?? true; + const readyListeners = new Set<() => void>(); + const navigation = { + isReady: () => ready, + getRootState: () => rootState, + dispatch, + addListener: (event: string, listener: () => void) => { + if (event === "ready") readyListeners.add(listener); + return () => readyListeners.delete(listener); }, + }; + const navigationRef = { + ...navigation, + current: navigation, } as unknown as NavigationContainerRefWithCurrent; - return { navigationRef, dispatch }; + return { + navigationRef, + dispatch, + becomeReady() { + ready = true; + for (const listener of readyListeners) listener(); + }, + }; } describe("navigateToHostWorkspaceRoute", () => { beforeEach(() => { vi.clearAllMocks(); - registerWorkspaceRouteNavigationRef({ - current: null, - } as unknown as NavigationContainerRefWithCurrent)(); + registerWorkspaceRouteNavigationRef(createNavigationRef({}).navigationRef)(); }); it("falls back to route navigation when no host route is mounted yet", () => { @@ -131,4 +144,74 @@ describe("navigateToHostWorkspaceRoute", () => { }, }); }); + + it("waits for the root navigator before applying a route intent", () => { + const { navigationRef, becomeReady } = createNavigationRef( + { + key: "root-stack", + routes: [{ key: "launcher", name: "index" }], + }, + { ready: false }, + ); + registerWorkspaceRouteNavigationRef(navigationRef); + const dismissTo = vi.fn(); + + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo }); + expect(dismissTo).not.toHaveBeenCalled(); + + becomeReady(); + expect(dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); + becomeReady(); + expect(dismissTo).toHaveBeenCalledTimes(1); + }); + + it("retains an intent submitted before the root ref registers", () => { + const dismissTo = vi.fn(); + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo }); + expect(dismissTo).not.toHaveBeenCalled(); + + const root = createNavigationRef({}, { ready: false }); + const unregister = registerWorkspaceRouteNavigationRef(root.navigationRef); + expect(dismissTo).not.toHaveBeenCalled(); + root.becomeReady(); + expect(dismissTo).toHaveBeenCalledExactlyOnceWith("/h/server-1/workspace/workspace-a"); + unregister(); + }); + + it("keeps only the latest route intent while the root navigator is unavailable", () => { + const { navigationRef, becomeReady } = createNavigationRef({}, { ready: false }); + registerWorkspaceRouteNavigationRef(navigationRef); + const firstDismissTo = vi.fn(); + const latestDismissTo = vi.fn(); + + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { + dismissTo: firstDismissTo, + }); + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-b", { + dismissTo: latestDismissTo, + }); + becomeReady(); + + expect(firstDismissTo).not.toHaveBeenCalled(); + expect(latestDismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b"); + }); + + it("preserves a pending route intent across root navigator re-registration", () => { + const first = createNavigationRef({}, { ready: false }); + const unregisterFirst = registerWorkspaceRouteNavigationRef(first.navigationRef); + const dismissTo = vi.fn(); + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo }); + + unregisterFirst(); + first.becomeReady(); + expect(dismissTo).not.toHaveBeenCalled(); + + const replacement = createNavigationRef({ + key: "root-stack", + routes: [{ key: "launcher", name: "index" }], + }); + registerWorkspaceRouteNavigationRef(replacement.navigationRef); + + expect(dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); + }); }); diff --git a/packages/app/src/navigation/workspace-route-navigation.ts b/packages/app/src/navigation/workspace-route-navigation.ts index 79fc1d8c2f..06d0d23e07 100644 --- a/packages/app/src/navigation/workspace-route-navigation.ts +++ b/packages/app/src/navigation/workspace-route-navigation.ts @@ -1,4 +1,8 @@ -import type { NavigationAction, NavigationContainerRefWithCurrent } from "@react-navigation/native"; +import type { + NavigationAction, + NavigationContainerRef, + NavigationContainerRefWithCurrent, +} from "@react-navigation/native"; import { router, type Href } from "expo-router"; import { encodeWorkspaceIdForPathSegment, @@ -19,12 +23,16 @@ const defaultNavigateToHostWorkspaceRouteDeps: NavigateToHostWorkspaceRouteDeps let rootNavigationRef: NavigationContainerRefWithCurrent | null = null; +let pendingIntent: { route: string; deps: NavigateToHostWorkspaceRouteDeps } | null = null; export function registerWorkspaceRouteNavigationRef( ref: NavigationContainerRefWithCurrent, ): () => void { rootNavigationRef = ref; + const unsubscribe = ref.addListener("ready", flushPendingIntent); + flushPendingIntent(); return () => { + unsubscribe(); if (rootNavigationRef === ref) { rootNavigationRef = null; } @@ -95,10 +103,12 @@ function findStackWithMountedRouteName( return null; } -function dispatchHostWorkspacePopTo(route: string): boolean { +function dispatchHostWorkspacePopTo( + route: string, + navigation: NavigationContainerRef, +): boolean { const selection = parseHostWorkspaceRouteFromPathname(route); - const navigation = rootNavigationRef?.current; - if (!selection || !navigation?.isReady()) { + if (!selection) { return false; } @@ -137,9 +147,15 @@ export function navigateToHostWorkspaceRoute( route: string, deps: NavigateToHostWorkspaceRouteDeps = defaultNavigateToHostWorkspaceRouteDeps, ): void { - if (dispatchHostWorkspacePopTo(route)) { - return; - } + pendingIntent = { route, deps }; + flushPendingIntent(); +} - deps.dismissTo(route); +function flushPendingIntent(): void { + const navigation = rootNavigationRef?.current; + if (!pendingIntent || !navigation?.isReady()) return; + + const { route, deps } = pendingIntent; + pendingIntent = null; + if (!dispatchHostWorkspacePopTo(route, navigation)) deps.dismissTo(route); } 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 d7deff40a7..1f4fdc3cc8 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -61,6 +61,7 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, () => undefined, + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot([entry(payload("live"))], []); replica.applyTurnLiveness("agent", { @@ -84,6 +85,7 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, () => undefined, + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); expect(replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("network"))).toBe( @@ -104,6 +106,7 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, (mutations) => commits.push([...mutations]), + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot([entry(payload("agent"))], []); commits.length = 0; @@ -144,6 +147,7 @@ describe("AgentDirectoryReplica", () => { serverId, (agentId) => stopped.push(agentId), () => undefined, + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot([entry(payload("agent"))], []); replica.applyTurnLiveness("agent", { @@ -166,6 +170,7 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, () => undefined, + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot( [ @@ -219,6 +224,7 @@ describe("AgentDirectoryReplica", () => { serverId, (agentId) => stopped.push(agentId), () => undefined, + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot( [ @@ -262,6 +268,7 @@ describe("AgentDirectoryReplica", () => { serverId, () => undefined, () => undefined, + (agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId), ); replica.commitSnapshot( [ @@ -289,10 +296,16 @@ 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 @@ -319,6 +332,7 @@ 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 1bdf3ea3de..ad41e2ae7c 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 } from "@/utils/agent-directory-sync"; +import { type AgentDirectoryDelta, type AgentRemovalReason } 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,52 +25,47 @@ 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); + this.storeProjection = new AgentStoreProjection(serverId, removeTimeline); } 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(); } - captureCache(agentId: string): AgentLifecycleToken { - this.pendingCacheReads.add(agentId); - return this.captureTimeline(agentId); - } - + // Saved rows sit under whatever the live stream already delivered for this connection. commitCached(agents: Map): void { - const merged = this.storeProjection.commitCached(agents); + const current = new Map( + [...agents].filter(([id]) => !this.lifecycleVersions.has(id) || this.members.has(id)), + ); + const merged = this.storeProjection.commitCached(current); 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 (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) { + if (!this.isTimelineCurrent(token)) { return false; } + const startsLifetime = !this.members.has(token.agentId); const existing = this.storeProjection.get(token.agentId); const timelineAgent = applyLegacyDaemonWorkspaceOwnership({ serverId: this.serverId, @@ -81,6 +76,10 @@ 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); @@ -99,7 +98,10 @@ export class AgentDirectoryReplica { this.advance(delta.agentId); } else { this.members.add(delta.agent.id); - if (!before) this.advance(delta.agent.id); + if (!before) { + this.resume(delta.agent.id); + this.acceptTimeline(delta.agent.id); + } } if (result.stoppedRunning) this.onStoppedRunning(result.agentId); this.persist( @@ -110,7 +112,12 @@ 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; @@ -124,22 +131,23 @@ export class AgentDirectoryReplica { const previous = this.storeProjection.snapshot(); const reconciled = reconcileAgentDirectory({ snapshot: entries, deltas }); const nextIds = new Set(reconciled.map((entry) => entry.agent.id)); - for (const agentId of this.pendingCacheReads) { - if (!nextIds.has(agentId)) this.advance(agentId); - } + const startedLifetimes = new Set(); for (const agentId of this.members) { if (!nextIds.has(agentId)) this.advance(agentId); } for (const agentId of nextIds) { - if (!this.members.has(agentId)) this.advance(agentId); + if (!this.members.has(agentId)) { + this.resume(agentId); + startedLifetimes.add(agentId); + } } for (const agentId of previous.keys()) { - if (!nextIds.has(agentId)) this.storeProjection.remove(agentId); + if (!nextIds.has(agentId)) this.storeProjection.remove(agentId, "scope"); } 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); @@ -201,7 +209,7 @@ export class AgentDirectoryReplica { remove(agentId: string): void { this.members.delete(agentId); this.advance(agentId); - this.storeProjection.remove(agentId); + this.storeProjection.remove(agentId, "deleted"); this.persist([{ kind: "agent", type: "delete", id: agentId }]); } @@ -225,6 +233,12 @@ 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 1593c4752f..030ddb1225 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -6,9 +6,17 @@ import { normalizeWorkspaceDescriptor, useSessionStore, } from "@/stores/session-store"; -import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors"; -import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; +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 { DirectoryRefreshSupersededError, DirectorySync, @@ -18,6 +26,7 @@ import { type WorkspaceFetchResult = Awaited>; type ProjectListResult = Awaited>; type AgentFetchResult = Awaited>; +type TimelineFetchResult = Awaited>; class FakeDirectoryClient { fetchAgentsCalls = 0; @@ -29,6 +38,10 @@ 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> @@ -98,6 +111,21 @@ 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; @@ -121,20 +149,102 @@ 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): { +function createDirectory( + serverId: string, + callbacks: ConstructorParameters[1] = noopCallbacks, +): { client: FakeDirectoryClient; directory: DirectorySync; } { serverIds.add(serverId); const client = new FakeDirectoryClient(); - const directory = new DirectorySync(serverId, { - onAgentStoppedRunning: () => undefined, - markAgentLoading: () => undefined, - markAgentReady: () => undefined, - markAgentError: () => undefined, - }); + const directory = new DirectorySync(serverId, callbacks); directory.connectionChanged({ client: client as unknown as DaemonClient, status: "online", @@ -176,12 +286,343 @@ 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); @@ -208,32 +649,24 @@ describe("DirectorySync session readiness", () => { projectRootPath: "/repo/cached", projectKind: "git", }); - 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, - }, - ); + 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, + }); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - await directory.restoreCachedDirectory(); + await directory.ready(); - expect( - useSessionStore.getState().sessions[serverId]?.workspaces.get(cachedWorkspace.id), - ).toEqual(cachedWorkspace); + 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(selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [serverId])).toEqual([ serverId, ]); @@ -242,62 +675,7 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - 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", () => { + it("persists accepted script status updates through the directory owner", async () => { const serverId = "script-status-owner"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -305,14 +683,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(), @@ -344,6 +722,7 @@ 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; @@ -471,14 +850,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 { @@ -553,14 +932,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]]), @@ -604,7 +983,7 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => { + it("advances the saved cursor only through completed catch-up", async () => { const serverId = "late-directory-cache"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -619,14 +998,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, }, @@ -645,133 +1024,95 @@ 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" }, + payload: { kind: "remove", id: "deleted-live", generation: "g", seq: 20 }, }); releaseCache({ agents: new Map(), workspaces: new Map(), projects: new Map(), - checkpoint: { workspaces: { generation: "stale", afterSeq: 99 } }, + 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: [] }, }); await refresh; - expect(client.lastWorkspaceOptions).not.toHaveProperty("sync.generation"); + 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 }, + }); directory.dispose(); }); - it("does not resurrect an agent deleted while its targeted cache row is loading", async () => { + it("does not resurrect an agent deleted while the baseline is loading", async () => { const serverId = "late-agent-cache"; serverIds.add(serverId); const client = new FakeDirectoryClient(); - 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; + 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 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, - }, - ); + 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 }, }); - 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" }, }); - releaseAgent(cachedAgent); - await load; + releaseLoad(); + await directory.ready(); expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false); directory.dispose(); }); - it("does not resurrect an agent removed by an authoritative snapshot while cache loads", async () => { + it("requests the authoritative snapshot only after the baseline is restored", async () => { const serverId = "late-agent-authoritative-snapshot"; serverIds.add(serverId); const client = new FakeDirectoryClient(); const cachedAgent = createAgent(serverId, "agent-1"); - 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 { cache, releaseLoad } = await savedDirectory(serverId, { + agents: new Map([[cachedAgent.id, cachedAgent]]), + workspaces: new Map(), + projects: new Map(), + checkpoint: { agents: { generation: "g", afterSeq: 5 } }, }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); @@ -781,18 +1122,22 @@ 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(); - const load = directory.loadCachedAgent(cachedAgent.id); - releaseNetwork({ - requestId: "authoritative-snapshot", - entries: [], - pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, - }); + await Promise.resolve(); + expect(client.fetchAgentsCalls).toBe(0); + releaseLoad(); await refresh; - releaseAgent(cachedAgent); - await load; + expect(client.lastAgentOptions).toMatchObject({ + sync: { generation: "g", afterSeq: 5 }, + }); expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false); directory.dispose(); }); @@ -804,14 +1149,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(), @@ -929,14 +1274,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(), diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index abcc98d5a7..0894de257a 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -3,7 +3,6 @@ import type { FetchAgentsEntry, FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; -import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once"; import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, @@ -18,7 +17,7 @@ import { shouldUseLegacyDaemonWorkspaceDirectory, stampLegacyWorkspaceIds, } from "@/workspace/legacy-daemon-workspaces"; -import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync"; +import type { AgentDirectoryDelta, AgentRemovalReason } from "@/utils/agent-directory-sync"; import { AgentDirectoryReplica } from "./agent-replica"; import { WorkspaceDirectoryReplica, @@ -33,13 +32,14 @@ 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,8 +108,6 @@ 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, @@ -139,6 +137,7 @@ export class DirectorySync { WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta >(); + private readonly timelineRequests = new Map>(); private readonly agents: AgentDirectoryReplica; private readonly workspaces: WorkspaceDirectoryReplica; private connection: DirectoryConnection = { @@ -148,10 +147,10 @@ export class DirectorySync { }; private unsubscribe: (() => void) | null = null; private readonly abortSessionWaits = new Set<() => void>(); - private cacheLoad: Promise | null = null; - private cacheAccepted = false; - private revision = 0; - private workspaceRevision = 0; + // 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 readonly routeDemandIds = new Set(); private readonly fullDemandSources = new Set(); private demandRefresh: Promise | null = null; @@ -161,7 +160,10 @@ 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; @@ -170,8 +172,19 @@ export class DirectorySync { ) { const persist = (mutations: readonly DirectoryReplicaMutation[]) => this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); - this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist); + this.agents = new AgentDirectoryReplica( + serverId, + callbacks.onAgentStoppedRunning, + persist, + callbacks.onAgentRemoved, + callbacks.onAgentAccepted, + ); this.workspaces = new WorkspaceDirectoryReplica(serverId); + this.baseline = this.restoreBaseline().catch(() => undefined); + } + + ready(): Promise { + return this.baseline; } connectionChanged(connection: DirectoryConnection): boolean { @@ -185,6 +198,7 @@ export class DirectorySync { return false; } this.flushAbortedTransactions(); + this.timelineRequests.clear(); this.unsubscribe?.(); this.unsubscribe = null; workspaceLabels.disconnect(this.serverId); @@ -196,63 +210,39 @@ export class DirectorySync { const subscriptions = [ client.on("agent_update", (message) => { if (message.type !== "agent_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - const recorded = this.agentTransactions.record(source, message.payload); - if (!recorded) { + if (!this.agentTransactions.record(source, message.payload)) { 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; - this.revision += 1; - this.workspaceRevision += 1; - const recorded = this.workspaceTransactions.record(source, message.payload); - if (!recorded) { + if (!this.workspaceTransactions.record(source, message.payload)) { 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; - this.revision += 1; - this.workspaceRevision += 1; - const recorded = this.workspaceTransactions.record(source, message.payload); - if (!recorded) { + if (!this.workspaceTransactions.record(source, message.payload)) { 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, }; - const recorded = this.workspaceTransactions.record(source, delta); - if (!recorded) { + if (!this.workspaceTransactions.record(source, delta)) { this.applyWorkspaceDelta(delta); - this.persistCheckpoint(); } }), client.on("agent_deleted", (message) => { - if (message.type === "agent_deleted" && this.isCurrent(client, source)) { - this.revision += 1; - this.agents.remove(message.payload.agentId); - this.persistCheckpoint(); - } + if (message.type !== "agent_deleted" || !this.isCurrent(client, source)) return; + this.agents.remove(message.payload.agentId); }), client.on("agent_archived", (message) => { - if (message.type === "agent_archived" && this.isCurrent(client, source)) { - this.revision += 1; - this.agents.archive(message.payload.agentId, message.payload.archivedAt); - this.persistCheckpoint(); - } + if (message.type !== "agent_archived" || !this.isCurrent(client, source)) return; + this.agents.archive(message.payload.agentId, message.payload.archivedAt); }), ]; this.unsubscribe = () => { @@ -266,9 +256,8 @@ 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) { - void this.loadCachedDirectory().catch(() => undefined); - if (this.getOnlineConnection()) void this.requestDemandRefresh().catch(() => undefined); + if (!wasDemanded && this.fullDemandSources.size > 0 && this.getOnlineConnection()) { + void this.requestDemandRefresh().catch(() => undefined); } } @@ -288,6 +277,8 @@ export class DirectorySync { } dispose(): void { + this.connection = { ...this.connection, status: "offline" }; + this.timelineRequests.clear(); this.flushAbortedTransactions(); this.abortPendingSessionWaits(); this.unsubscribe?.(); @@ -304,9 +295,7 @@ 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.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve(); - } + if (!this.getOnlineConnection()) return this.baseline; const source = this.connection.source; if ( !force && @@ -319,8 +308,8 @@ export class DirectorySync { this.fullDemandSources.size > 0 ? this.refreshAll() : Promise.all([ - this.refreshAgentsInternal({ subscribe: {} }, false), - this.refreshWorkspacesInternal({ subscribe: true }, false), + this.refreshAgents({ subscribe: {} }), + this.refreshWorkspaces({ subscribe: true }), ]).then(() => undefined); this.demandRefresh = refresh .then(() => { @@ -345,57 +334,13 @@ export class DirectorySync { return this.requestDemandRefresh(true); } - async loadCachedAgent(agentId: string): Promise { + private async restoreBaseline(): Promise { if (!this.checkpoints) return; - 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 || this.revision !== revision) return; - if (!useSessionStore.getState().sessions[this.serverId]) return; - this.agents.commitCached(cached.agents); - this.workspaces.commitCached(cached); - this.cursors = cached.checkpoint ?? {}; - this.cacheAccepted = true; - })(); - return this.cacheLoad; - } - - restoreCachedDirectory(): Promise { - return this.loadCachedDirectory(); + 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 ?? {}; } applyAgentTurnLiveness( @@ -412,33 +357,36 @@ export class DirectorySync { async fetchTimeline( agentId: string, request: Parameters[1], - ): Promise>> { - const { client } = this.requireOnline(); + replaceTail = false, + ): Promise { + const { client, source } = this.requireOnline(); const token = this.agents.captureTimeline(agentId); - const page = await fetchAgentTimelineOnce(client, agentId, request); - if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) { - this.revision += 1; - this.persistCheckpoint(); - } - return page; + 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; } 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), @@ -446,6 +394,8 @@ 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, @@ -454,6 +404,7 @@ 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)) { @@ -510,9 +461,8 @@ export class DirectorySync { : delta, ) : completion.deltas; - this.revision += 1; const agents = this.commitAgentSnapshot(completion.snapshot, deltas); - this.persistAgentCursors(completion.snapshot, completion.deltas); + this.persistAgentCursors(completion.snapshot); this.persistCheckpoint(); this.callbacks.markAgentReady(); return { agents, subscriptionId: completion.snapshot.subscriptionId }; @@ -527,26 +477,27 @@ 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) return; + if (!onlineConnection) { + await this.baseline; + 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(useSessionStore.getState().sessions[this.serverId]?.workspaces), - projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects), + workspaces: new Map(), + projects: new Map(), 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) { @@ -725,17 +676,6 @@ 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 === @@ -752,12 +692,8 @@ export class DirectorySync { : this.agents.commitSnapshot(snapshot.entries, deltas); } - private persistAgentCursors( - snapshot: AgentSnapshot, - deltas: readonly AgentDirectoryDelta[], - ): void { + private persistAgentCursors(snapshot: AgentSnapshot): void { if (snapshot.syncCursor) this.writeCursor("agents", snapshot.syncCursor); - for (const delta of deltas) this.noteLiveCursor("agents", delta); } private async fetchProjectSnapshot( @@ -801,8 +737,6 @@ 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(); @@ -822,14 +756,11 @@ 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); } @@ -874,17 +805,6 @@ 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 0566da5aaf..b4504d9c8b 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -2,7 +2,11 @@ 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 } from "@/utils/agent-directory-sync"; +import { + buildAgentDirectoryState, + type AgentDirectoryDelta, + type AgentRemovalReason, +} 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"; @@ -23,7 +27,10 @@ function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent } export class AgentStoreProjection { - constructor(private readonly serverId: string) {} + constructor( + private readonly serverId: string, + private readonly removeTimeline: (agentId: string, reason: AgentRemovalReason) => void, + ) {} snapshot(): Map { return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); @@ -65,7 +72,7 @@ export class AgentStoreProjection { agent?: Agent; } { if (delta.kind === "remove") { - this.remove(delta.agentId); + this.remove(delta.agentId, "scope"); return { agentId: delta.agentId, stoppedRunning: false }; } const normalized = normalizeAgentSnapshot(delta.agent, this.serverId); @@ -141,7 +148,7 @@ export class AgentStoreProjection { useSessionStore.getState().setPendingPermissions(this.serverId, pending); } - remove(agentId: string): void { + remove(agentId: string, reason: AgentRemovalReason): void { const store = useSessionStore.getState(); const removeKey = (current: Map): Map => { if (!current.has(agentId)) return current; @@ -153,16 +160,13 @@ export class AgentStoreProjection { store.setAgents(this.serverId, removeKey); store.setAgentDetails(this.serverId, removeKey); store.setQueuedMessages(this.serverId, removeKey); - store.setAgentTimelineCursor(this.serverId, removeKey); + this.removeTimeline(agentId, reason); 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 84f08b22fc..ffb2023736 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 targeted cached workspace while its archive is pending", () => { +it("does not restore a cached workspace while its archive is pending", () => { const serverId = "cached-workspace-during-archive"; const workspaceId = "archived-workspace"; const store = useSessionStore.getState(); @@ -222,7 +222,10 @@ it("does not restore a targeted cached workspace while its archive is pending", markWorkspaceArchivePending({ serverId, workspaceId }); try { - replica.commitCachedWorkspace(normalizeWorkspaceDescriptor(workspace(workspaceId)), undefined); + replica.commitCached({ + workspaces: new Map([[workspaceId, normalizeWorkspaceDescriptor(workspace(workspaceId))]]), + projects: new Map(), + }); 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 d229c196a2..5e8951edf3 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -58,15 +58,6 @@ 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 b77d53f646..a7ba9eb784 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -22,6 +22,8 @@ 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" }; @@ -37,6 +39,13 @@ 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[] = []; @@ -466,16 +475,13 @@ function createMemoryReplicaRowStore(): ReplicaRowStore { `${row.serverId}:${row.kind}:${row.id}`; return { open: async () => undefined, - read: async (serverId, kinds, ids) => { - const acceptedKinds = new Set(kinds); - const acceptedIds = ids ? new Set(ids) : null; - return [...rows.values()].filter( + read: async (serverIds, kinds, ids) => + [...rows.values()].filter( (row) => - row.serverId === serverId && - acceptedKinds.has(row.kind) && - (!acceptedIds || acceptedIds.has(row.id)), - ); - }, + serverIds.includes(row.serverId) && + kinds.includes(row.kind) && + (!ids || ids.includes(row.id)), + ), readAll: async () => { const hosts = new Map(); for (const row of rows.values()) { @@ -1599,15 +1605,74 @@ 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 fullScans = 0; + let directoryReads = 0; const backingStore = createMemoryReplicaRowStore(); const replicaRowStore: ReplicaRowStore = { ...backingStore, read: async (...args) => { - fullScans += 1; + directoryReads += 1; return backingStore.read(...args); }, }; @@ -1632,7 +1697,7 @@ describe("HostRuntimeStore", () => { store.boot(); await registryLoaded; - await vi.waitFor(() => expect(fullScans).toBe(1)); + await vi.waitFor(() => expect(directoryReads).toBe(1)); expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({ client: null, hasHydratedAgents: false, @@ -2329,10 +2394,15 @@ describe("HostRuntimeStore", () => { host.serverId, new Map([[recoveredAgent.agent.id, replicaAgent(recoveredAgent.agent, host.serverId)]]), ); - sessionStore.setAgentTimelineCursor( - host.serverId, - new Map([[recoveredAgent.agent.id, { epoch: "epoch", startSeq: 10, endSeq: 20 }]]), - ); + sessionStore.applyAgentTimelineResponseState(host.serverId, recoveredAgent.agent.id, { + items: [], + head: [], + range: { epoch: "epoch", startSeq: 10, endSeq: 20 }, + older: "none", + newer: false, + synchronized: false, + submissions: [], + }); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ @@ -2755,8 +2825,9 @@ describe("HostRuntimeStore", () => { getClientId: async () => "cid_drain_submission", }, }); + store.syncHosts([{ ...host, connections: [] }]); const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.updateSessionServerInfo(host.serverId, { serverId: host.serverId, hostname: null, @@ -2802,7 +2873,7 @@ describe("HostRuntimeStore", () => { expect(session?.messageSubmissions.get("agent")).toBeDefined(); send.resolve(); - useSessionStore.getState().clearSession(host.serverId); + store.syncHosts([]); }); it("restores an automatically drained message when sending fails", async () => { @@ -2820,8 +2891,9 @@ describe("HostRuntimeStore", () => { getClientId: async () => "cid_failed_queue_drain", }, }); + store.syncHosts([{ ...host, connections: [] }]); const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.setQueuedMessages( host.serverId, new Map([ @@ -2847,7 +2919,7 @@ describe("HostRuntimeStore", () => { ]); }); - useSessionStore.getState().clearSession(host.serverId); + store.syncHosts([]); }); it("serializes queued-message drains for the same agent", async () => { @@ -2866,8 +2938,9 @@ describe("HostRuntimeStore", () => { getClientId: async () => "cid_serialized_queue_drain", }, }); + store.syncHosts([{ ...host, connections: [] }]); const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.setQueuedMessages( host.serverId, new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]), @@ -2884,7 +2957,7 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get("agent"), ).toEqual([]); }); - useSessionStore.getState().clearSession(host.serverId); + store.syncHosts([]); }); it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => { @@ -2901,8 +2974,9 @@ describe("HostRuntimeStore", () => { getClientId: async () => "cid_legacy_queue_attachment", }, }); + store.syncHosts([{ ...host, connections: [] }]); const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.updateSessionServerInfo(host.serverId, { serverId: host.serverId, hostname: null, @@ -3141,6 +3215,235 @@ 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 ad33968740..7ab9902597 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,3 +1,4 @@ +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"; @@ -70,11 +71,8 @@ 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"; @@ -1404,6 +1402,7 @@ 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; @@ -1521,12 +1520,6 @@ 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 { @@ -1663,11 +1656,17 @@ export class HostRuntimeStore { projectIconCache.reconcileServerId(oldServerId, newServerId); this.directorySyncByServer.get(oldServerId)?.dispose(); this.directorySyncByServer.delete(oldServerId); - this.timelineReplicaByServer.delete(oldServerId); + const timelineReplica = this.getTimelineReplica(oldServerId); + rekeyMap(this.timelineReplicaByServer, oldServerId, newServerId); 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), @@ -1675,18 +1674,16 @@ 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); - this.syncSessionReplica(newServerId, snapshot); + useSessionStore + .getState() + .initializeSession(newServerId, snapshot.client, snapshot.clientGeneration); + timelineReplica.reconcileServerId(newServerId); + this.installViewedTimelineOwner(newServerId); directory.connectionChanged({ client: snapshot.client, status: snapshot.connectionStatus === "online" ? "online" : "offline", @@ -2057,6 +2054,9 @@ 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,7 +2086,13 @@ 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), @@ -2099,9 +2105,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()); @@ -2213,7 +2219,7 @@ export class HostRuntimeStore { supportsForgeAttachments, }), encodeImages, - submission: createMessageSubmissionWriter(serverId), + submission: createMessageSubmissionWriter(serverId, this.getTimelineReplica(serverId)), }); }, }) @@ -2343,6 +2349,7 @@ 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); } @@ -2391,49 +2398,40 @@ export class HostRuntimeStore { return () => directory.setDemand(source, false); } - 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 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 { + getTimelineReplica(serverId: string): TimelineReplica { const replica = this.timelineReplicaByServer.get(serverId); if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`); - await replica.prepare(agentId); + return replica; } - fetchAgentTimeline( + async 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); + return directory.fetchTimeline(agentId, request, replaceTail); } - 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({ + 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({ serverId, - replica, - replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds), - drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId), - ports, + replica: this.getTimelineReplica(serverId), + replaceDemandedAgentIds: (ids) => + this.directorySyncByServer.get(serverId)?.setAgentRouteDemand(ids), + drainQueuedAgentMessage: (id) => this.drainQueuedAgentMessage(serverId, id), }); + 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 df6c3a0aa0..2a603490c6 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -25,41 +25,58 @@ class MemoryStorage implements ReplicaRowStore { readonly rows = new Map(); readonly changes: ReplicaRowChanges[] = []; readonly reads: Array<{ - serverId: string; + serverIds: readonly 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; - onRead: (() => void) | null = null; + readAllGate: Promise | null = null; + writeGate: Promise | null = null; + renameGate: Promise | null = null; private key(row: Pick): string { return `${row.serverId}:${row.kind}:${row.id}`; } - async open(): Promise {} + async open(): Promise { + if (this.nextOpenFailure) { + const error = this.nextOpenFailure; + this.nextOpenFailure = null; + throw error; + } + } async read( - serverId: string, + serverIds: readonly string[], kinds: readonly ReplicaRow["kind"][], ids?: readonly string[], ): Promise { - this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) }); - this.onRead?.(); + this.reads.push({ serverIds, kinds, ids }); + const rows = [...this.rows.values()]; await this.readGate; - const acceptedKinds = new Set(kinds); - const acceptedIds = ids ? new Set(ids) : null; - return [...this.rows.values()].filter( + return rows.filter( (row) => - row.serverId === serverId && - acceptedKinds.has(row.kind) && - (!acceptedIds || acceptedIds.has(row.id)), + serverIds.includes(row.serverId) && + kinds.includes(row.kind) && + (!ids || ids.includes(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) ?? []; @@ -71,6 +88,7 @@ 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; @@ -82,10 +100,16 @@ 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); @@ -170,6 +194,7 @@ 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 }] }, }; } @@ -241,6 +266,359 @@ 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); @@ -409,9 +787,10 @@ describe("ReplicaCache", () => { deleteDirectory(cache, SERVER_ID); - 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); + 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); }); it("fails closed when an accepted deletion cannot be persisted before a read", async () => { @@ -423,25 +802,23 @@ describe("ReplicaCache", () => { deleteDirectory(cache, SERVER_ID); - expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); + expect((await cache.readDirectory(SERVER_ID)).workspaces.size).toBe(0); }); - it("discards a durable read when the host changes while it is in flight", async () => { + it("applies a deletion accepted while a scoped directory read is still loading", async () => { const storage = new MemoryStorage(); - const cache = createCache(storage); - commitDirectory(cache, SERVER_ID, directory()); - await cache.flush(); - const started = deferred(); + const writer = createCache(storage); + commitDirectory(writer, SERVER_ID, directory()); + await writer.flush(); const release = deferred(); - storage.onRead = started.resolve; storage.readGate = release.promise; + const cache = createCache(storage); - const reading = cache.readAgent(SERVER_ID, "agent-1"); - await started.promise; + const reading = cache.readDirectory(SERVER_ID); deleteDirectory(cache, SERVER_ID); release.resolve(); - expect(await reading).toBeUndefined(); + expect((await reading).agents.size).toBe(0); }); it("never reads a timeline older than an accepted deferred replacement", async () => { @@ -513,39 +890,6 @@ 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`, { @@ -570,6 +914,7 @@ 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"); @@ -601,7 +946,7 @@ describe("ReplicaCache", () => { commitDirectory(cache, otherServerId, directory()); await cache.flush(); - expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); + expect((await cache.readDirectory(SERVER_ID)).agents.has("agent-1")).toBe(false); cache.commitTimeline(otherServerId, "agent-1", timeline("x".repeat(1_000))); await cache.flush(); @@ -627,7 +972,9 @@ describe("ReplicaCache", () => { payload: "{bad", }); - expect(await createCache(storage).readAgent(SERVER_ID, "agent-1")).toBeUndefined(); + const cache = createCache(storage); + expect((await cache.readDirectory(SERVER_ID)).agents.has("agent-1")).toBe(false); + await cache.flush(); expect(storage.changes.at(-1)).toMatchObject({ deletes: [{ serverId: SERVER_ID, kind: "agent", id: "agent-1" }], @@ -657,7 +1004,9 @@ describe("ReplicaCache", () => { payload: "{bad", }); - const restored = await createCache(storage).readDirectory(SERVER_ID); + const cache = createCache(storage); + const restored = await cache.readDirectory(SERVER_ID); + await cache.flush(); expect(restored.checkpoint).toEqual({ projects: { generation: "g", afterSeq: 4 }, @@ -688,6 +1037,66 @@ 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); @@ -703,21 +1112,6 @@ 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 }); @@ -759,7 +1153,7 @@ describe("ReplicaCache", () => { }); cache.setHosts([SERVER_ID]); - await cache.readAgent(SERVER_ID, "missing"); + await cache.readTimeline(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 c1db2f8418..de02987c21 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { AgentStatusSchema, + AgentAttachmentSchema, AgentTimelineItemPayloadSchema, WorkspaceGitHubRuntimePayloadSchema, } from "@getpaseo/protocol/messages"; @@ -45,11 +46,6 @@ export interface CachedDirectory { checkpoint?: DirectoryCheckpoint; } -export interface CachedWorkspace { - workspace: WorkspaceDescriptor; - project?: ProjectDescriptor; -} - export interface DirectoryCursor { generation: string; afterSeq: number; @@ -69,13 +65,32 @@ 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(), @@ -90,6 +105,8 @@ 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, @@ -115,6 +132,8 @@ 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(), @@ -379,10 +398,7 @@ const DirectoryCheckpointSchema = z.strictObject({ agents: DirectoryCursorSchema.optional(), }); -function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null { - if (!stored) { - return null; - } +function deserializeTimeline(stored: StoredTimeline): CachedTimeline { return { agentId: stored.agentId, items: stored.items.map(deserializeTimelineItem), @@ -395,6 +411,7 @@ 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(), }; @@ -422,13 +439,7 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { const base = timelineBase(item); switch (item.kind) { case "user_message": - return { - ...base, - kind: item.kind, - ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), - ...(item.messageId ? { messageId: item.messageId } : {}), - text: item.text, - }; + return { ...item, ...base }; case "assistant_message": return { ...base, @@ -490,6 +501,7 @@ 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, @@ -509,18 +521,13 @@ 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 { - ...base, - kind: item.kind, - ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), - ...(item.messageId ? { messageId: item.messageId } : {}), - text: item.text, - }; + return { ...item, ...base }; case "assistant_message": return { ...base, @@ -727,44 +734,43 @@ function serializeProject(project: ProjectDescriptor): StoredProject { }; } -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 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); +// 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; - const canPersistCoverage = + return ( range !== null && range.retainedRanges === undefined && - canonicalItems.length <= MAX_TIMELINE_ITEMS && - items.length === canonicalItems.length && - canonicalItems.every( + timeline.items.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 { + (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 serializeTimelinePayload(timeline: CachedTimeline): string { + const items = timeline.items + .filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item)) + .map(serializeTimelineItem) + .filter((item) => item !== null); + const range = timeline.range; + const certified = isCertifiedTimeline(timeline); + return JSON.stringify({ agentId: timeline.agentId, - items: items.slice(-MAX_TIMELINE_ITEMS), - range: canPersistCoverage - ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } - : null, - hasOlder: canPersistCoverage ? timeline.hasOlder : false, - }; + items, + range: + certified && range + ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } + : null, + hasOlder: certified && timeline.hasOlder, + } satisfies StoredTimeline); } function rowKey(key: Pick): string { @@ -820,12 +826,6 @@ interface DirectoryReadAccumulator { checkpoint?: DirectoryCheckpoint; } -interface PendingReplicaChanges { - upserts: StructuredReplicaUpsert[]; - deletes: ReplicaRowKey[]; - baselines: Set; -} - function applyDirectoryRow( serverId: string, row: ReplicaRow, @@ -868,22 +868,43 @@ 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 readonly activeServerIds = new Set(); - private readonly hostRevisions = new Map(); + private activeServerIds = new Set(); private readonly storedRows = new Map>(); private readonly hostBytes = new Map(); private readonly hostWriteOrder = new Map(); - private pendingUpserts = new Map(); - private pendingDeletes = new Map(); - private pendingBaselines = new Set(); - private readonly invalidatedHosts = new Set(); - private readonly maxBytes: number; 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 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 persistTimer: ReturnType | null = null; - private writeQueue: Promise = Promise.resolve(); - private preparePromise: Promise | null = null; - private storedIndexPromise: Promise | 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(); constructor( private readonly rowStore: ReplicaRowStore, @@ -895,50 +916,8 @@ 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, ["agent", "workspace", "project", "checkpoint"]); + const rows = await this.readRows(serverId, DIRECTORY_KINDS); const result: DirectoryReadAccumulator = { agents: new Map(), workspaces: new Map(), @@ -947,6 +926,23 @@ 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 { @@ -960,113 +956,100 @@ export class ReplicaCache { for (const entity of invalidEntities) delete result.checkpoint[entity]; } if (invalidRows.length > 0) { - await this.repairInvalidDirectoryRows(serverId, invalidRows, result.checkpoint); + this.repairInvalidDirectoryRows(serverId, invalidRows, result.checkpoint); } return result; } async readTimeline(serverId: string, agentId: string): Promise { - const rows = await this.readRows(serverId, ["timeline"], [agentId]); - const row = rows[0]; + const row = (await this.readRows(serverId, ["timeline"], [agentId]))[0]; if (!row) 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; + 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; + } } + 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 []; - 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 []; - } - } - - 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) { + 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 { - checkpoint = parseStoredPayload(DirectoryCheckpointSchema, checkpointRow.payload); - checkpoint = { ...checkpoint }; - delete checkpoint[invalidEntity]; + await this.rowStore.open(); + stored = await this.rowStore.read(sourceOrder, kinds, ids); } catch { - checkpoint = undefined; + // Accepted commits remain readable even if the disk is unavailable. } } - 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); - }); + 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 async repairInvalidDirectoryRows( + private repairInvalidDirectoryRows( serverId: string, invalidRows: ReplicaRow[], checkpoint: DirectoryCheckpoint | undefined, - ): Promise { + ): void { + for (const row of invalidRows) this.queueDelete(row); const checkpointWasInvalid = invalidRows.some((row) => row.kind === "checkpoint"); - 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); - }); + if (checkpoint !== undefined && !checkpointWasInvalid) { + this.queueUpsert({ + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + value: checkpoint, + }); + } + this.schedulePersist(); } commitDirectoryMutations( @@ -1076,10 +1059,9 @@ 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.queueEntityDelete(serverId, mutation.kind, mutation.id); + this.queueDelete({ serverId, kind: mutation.kind, id: mutation.id }); } else { this.queueUpsert({ serverId, @@ -1102,15 +1084,18 @@ 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, row] of this.pendingUpserts) { - if (row.serverId === serverId && row.kind !== "timeline") this.pendingUpserts.delete(key); + 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.pendingDeletes) { - if (row.serverId === serverId && row.kind !== "timeline") this.pendingDeletes.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); } - this.pendingBaselines.add(serverId); for (const [id, value] of directory.agents) { this.queueUpsert({ serverId, kind: "agent", id, value }); } @@ -1131,10 +1116,23 @@ export class ReplicaCache { this.schedulePersist(); } - commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void { + 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 { if (!this.activeServerIds.has(serverId)) return; if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload"); - this.advanceHostRevision(serverId); + // 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.queueUpsert({ serverId, kind: "timeline", id: agentId, value: timeline }); this.schedulePersist(); } @@ -1142,148 +1140,193 @@ export class ReplicaCache { setHosts(serverIds: Iterable): void { const next = new Set(serverIds); const removed = [...this.activeServerIds].filter((serverId) => !next.has(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); + this.activeServerIds = next; + for (const serverId of next) { + if (!this.hostSources.has(serverId)) this.hostSources.set(serverId, new Set([serverId])); + } for (const serverId of removed) { - this.removeStoredHost(serverId); + this.dropAcceptedHost(serverId); this.dropPendingHostChanges(serverId); this.invalidatedHosts.delete(serverId); - this.queueOperation(() => this.rowStore.deleteHost(serverId)); + this.applyToImage(() => this.removeStoredHost(serverId)); + void this.deleteStoredSource(serverId); } } reconcileServerId(oldServerId: string, newServerId: string): void { - 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); - } + 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); + } if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId); - this.queueOperation(() => this.rowStore.renameHost(oldServerId, 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); + }); } async flush(): Promise { await this.persist(); - await this.writeQueue.catch(() => undefined); + await this.disk; } - private async flushPending(): Promise { - await this.persist(); + private ready(): Promise { + this.image ??= this.queueDisk(() => this.loadStoredRows()).then( + () => true, + () => { + this.image = null; + return false; + }, + ); + return this.image; } - private async persist(): Promise { + 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 { 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; - 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; + 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); + } } - 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 queueUpsert(upsert: StructuredReplicaUpsert): void { + const row = { key: upsert, upsert }; + this.pendingRows.set(pendingRowKey(upsert), row); + this.acceptedRows.set(pendingRowKey(upsert), row); } private queueDelete(key: ReplicaRowKey): void { - const pendingKey = pendingRowKey(key); - this.pendingUpserts.delete(pendingKey); - this.pendingDeletes.set(pendingKey, key); + 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); } private hasPendingChanges(): boolean { - return ( - this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0 || this.pendingBaselines.size > 0 - ); + return this.pendingRows.size > 0 || this.pendingBaselines.size > 0; } - 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 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])); + private materializeBatch(batch: PendingBatch): ReplicaRowChanges { + const deletes = new Map(); 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 pending of batch.rows.values()) { + if (pending.upsert === null) deletes.set(pendingRowKey(pending.key), pending.key); + else upserts.push(materializeUpsert(pending.upsert)); } - for (const serverId of pending.baselines) { + for (const serverId of batch.baselines.keys()) { const replacementKeys = new Set( upserts.filter((row) => row.serverId === serverId && row.kind !== "timeline").map(rowKey), ); @@ -1296,60 +1339,6 @@ 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) { @@ -1423,12 +1412,13 @@ export class ReplicaCache { while (projectedTotal > this.maxBytes) { const serverId = writeOrder.shift(); if (serverId === undefined) break; - const bytes = projectedBytes.get(serverId) ?? 0; - projectedTotal -= bytes; + projectedTotal -= projectedBytes.get(serverId) ?? 0; projectedBytes.delete(serverId); evicted.add(serverId); this.invalidatedHosts.add(serverId); - if (this.storedRows.has(serverId)) await this.rowStore.deleteHost(serverId); + this.dropPendingHostChanges(serverId); + this.dropAcceptedHost(serverId); + await this.deleteStoredSource(serverId).catch(() => undefined); this.removeStoredHost(serverId); } @@ -1441,6 +1431,22 @@ 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); @@ -1448,79 +1454,74 @@ 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, 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); + for (const [key, pending] of this.pendingRows) { + if (pending.key.serverId === serverId) this.pendingRows.delete(key); } } private renamePendingHostChanges(oldServerId: string, newServerId: string): void { - const changes = this.drainPendingChanges(); - for (const key of changes.deletes) { - this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key); + 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, + }); } - 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); + const baseline = this.pendingBaselines.get(oldServerId); + if (baseline) { + this.pendingBaselines.delete(oldServerId); + this.pendingBaselines.set(newServerId, baseline); } 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.flushPending(); + void this.persist(); }, 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 daa8ee28ef..e7de78ad28 100644 --- a/packages/app/src/runtime/replica-cache/row-store-sqlite.ts +++ b/packages/app/src/runtime/replica-cache/row-store-sqlite.ts @@ -108,20 +108,17 @@ export function createSqliteReplicaRowStore( } async function read( - serverId: string, + serverIds: readonly string[], kinds: readonly ReplicaRow["kind"][], ids?: readonly string[], ): Promise { - 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 ?? [])], + 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 ?? [])], ); - return storedRows.map((row) => ({ + return rows.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 2e267e812d..ba6848264c 100644 --- a/packages/app/src/runtime/replica-cache/row-store.contract.ts +++ b/packages/app/src/runtime/replica-cache/row-store.contract.ts @@ -55,20 +55,23 @@ export function runReplicaRowStoreContract( ]); }); - it("reads only requested kinds for one host", async () => { + it("reads only requested hosts, kinds and identities", async () => { + const selected = row("server-a", "timeline", "wanted", "page"); await store.apply({ - upserts: [ - row("server-a", "agent", "agent-1", "agent"), - row("server-a", "timeline", "singleton", "timeline"), - row("server-b", "agent", "agent-2", "other host"), - ], deletes: [], + upserts: [ + selected, + row("server-a", "timeline", "other", "unrelated timeline"), + row("server-a", "agent", "wanted", "directory"), + row("server-b", "timeline", "wanted", "other host"), + ], }); - - expect(await store.read("server-a", ["timeline"])).toEqual([ - row("server-a", "timeline", "singleton", "timeline"), + 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", ["agent"], ["missing"])).toEqual([]); + expect(await store.read([], ["timeline"])).toEqual([]); + expect(await store.read(["server-a"], ["timeline"], [])).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 b9cd02d99c..77b023cc18 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( - serverId: string, + serverIds: readonly 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 472143ee34..1630fb8e01 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,28 +24,41 @@ runReplicaRowStoreContract("IndexedDB", async () => { }; }); -it("uses exact IndexedDB keys for targeted rows instead of scanning the host", async () => { - const store = createIndexedDbReplicaRowStore({ +it("reads requested IndexedDB keys without scanning unrelated rows", async () => { + const options = { databaseName: `replica-row-store-targeted-${databaseSequence++}`, schemaVersion: 1, - }); - await store.open(); - await store.apply({ + }; + const writer = createIndexedDbReplicaRowStore(options); + await writer.open(); + await writer.apply({ upserts: [ - { serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" }, - { serverId: "server-a", kind: "workspace", id: "workspace-1", payload: "workspace" }, + { 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" }, ], deletes: [], }); + const reader = createIndexedDbReplicaRowStore(options); + await reader.open(); const get = vi.spyOn(FakeIDBObjectStore.prototype, "get"); const getAll = vi.spyOn(FakeIDBObjectStore.prototype, "getAll"); - - 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(); + 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(); + } }); 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 12f484bd91..8b843fc8b6 100644 --- a/packages/app/src/runtime/replica-cache/row-store.web.ts +++ b/packages/app/src/runtime/replica-cache/row-store.web.ts @@ -136,38 +136,6 @@ 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); @@ -180,6 +148,39 @@ 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 a27a1ed418..4f599c3d84 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -61,6 +61,7 @@ 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, @@ -1546,12 +1547,6 @@ 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); @@ -1914,25 +1909,23 @@ function WorkspaceScreenContent({ routeFocused: isRouteFocused, focusedPaneOnly: syncFocusedPaneOnly, }); - useEffect(() => { - for (const agentId of visibleAgentIds) { - void getHostRuntimeStore() - .prepareAgentTimeline(normalizedServerId, agentId) - .catch(() => undefined); - } - }, [normalizedServerId, visibleAgentIds]); + const visibleTimelineDeclarationRef = useRef(null); useLayoutEffect(() => { if (!persistenceKey || !viewedTimelineSync) { return; } - viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, visibleAgentIds); - }, [persistenceKey, viewedTimelineSync, visibleAgentIds]); - useEffect(() => { - if (!persistenceKey || !viewedTimelineSync) { - return; - } - return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []); + 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); + }, [persistenceKey, viewedTimelineSync, visibleAgentIds]); 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 c53eae4c49..87c50a9155 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -1,3 +1,4 @@ +import { createTimelineReplica, type TimelineReplica } from "@/timeline/replica"; import { afterEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -85,8 +86,37 @@ 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() { @@ -141,16 +171,38 @@ describe("agent task state", () => { ); const tasks = [{ id: "1", text: "Inspect", status: "pending" as const, completed: false }]; - useSessionStore - .getState() - .setAgentStreamState("test-server", "agent-1", { taskSnapshot: tasks }); + 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().setIsPlayingAudio("test-server", true); - useSessionStore - .getState() - .setAgentStreamState("test-server", "agent-1", { taskSnapshot: [...tasks] }); - useSessionStore.getState().setAgentStreamState("test-server", "agent-1", { - taskSnapshot: [{ ...tasks[0], status: "completed", completed: 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, + ); unsubscribe(); expect(snapshots).toEqual([["Inspect"], ["Inspect"]]); @@ -174,7 +226,7 @@ describe("agent task state", () => { older: "none", newer: false, synchronized: true, - acknowledgedClientMessageIds: [], + submissions: [], }); expect(useSessionStore.getState().sessions["test-server"]?.agentTasks.get("agent-1")).toEqual( @@ -184,6 +236,107 @@ 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[] = [ @@ -202,7 +355,7 @@ describe("agent timeline state", () => { older: "available", newer: false, synchronized: true, - acknowledgedClientMessageIds: [], + submissions: [], }); expect( @@ -225,7 +378,7 @@ describe("agent timeline state", () => { older: "none", newer: false, synchronized: true, - acknowledgedClientMessageIds: [], + submissions: [], }); expect( @@ -243,7 +396,7 @@ describe("agent timeline state", () => { older: "available", newer: false, synchronized: true, - acknowledgedClientMessageIds: [], + submissions: [], }); store.applyAgentTimelineResponseState("test-server", "agent-1", { @@ -253,7 +406,7 @@ describe("agent timeline state", () => { older: "unchanged", newer: false, synchronized: false, - acknowledgedClientMessageIds: [], + submissions: [], }); expect( @@ -306,15 +459,12 @@ describe("message submission ordering", () => { ] as const; function applyStep(step: (typeof steps)[number]): void { - const store = useSessionStore.getState(); if (step === "accept") { - store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); + timelineReplica.acceptSubmission(agentId, clientMessageId); return; } if (step === "canonical-row") { - store.setAgentStreamState("test-server", agentId, { - acknowledgedClientMessageIds: [clientMessageId], - }); + acknowledgeSubmission(agentId, clientMessageId); return; } if (step === "stream-open") { @@ -351,9 +501,7 @@ describe("message submission ordering", () => { for (const ordering of permutations(steps)) { useSessionStore.getState().clearSession("test-server"); initializeTestSession(); - useSessionStore - .getState() - .beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); + timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true); let canonicalObserved = false; for (const step of ordering) { @@ -368,11 +516,8 @@ describe("message submission ordering", () => { } } - const store = useSessionStore.getState(); - store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); - store.setAgentStreamState("test-server", agentId, { - acknowledgedClientMessageIds: [clientMessageId], - }); + timelineReplica.acceptSubmission(agentId, clientMessageId); + acknowledgeSubmission(agentId, clientMessageId); applyTestTurn("test-server", agentId, [ { type: "stream_close", turnId: "turn-1" }, { type: "snapshot", activeTurn: null }, @@ -402,9 +547,7 @@ describe("message submission ordering", () => { }); }); - useSessionStore - .getState() - .beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); + timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true); unsubscribe(); expect(notifications).toEqual([{ hasOptimisticRow: true, isActive: true }]); @@ -412,13 +555,10 @@ describe("message submission ordering", () => { it("keeps another unacknowledged submission active after one row is acknowledged", () => { initializeTestSession(); - 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], - }); + timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true); + timelineReplica.beginSubmission(agentId, submittedMessage("client-2"), true); + timelineReplica.acceptSubmission(agentId, clientMessageId); + acknowledgeSubmission(agentId, clientMessageId); expect( selectAgentTurnPresentation(useSessionStore.getState().sessions["test-server"], agentId) @@ -430,9 +570,7 @@ describe("message submission ordering", () => { initializeTestSession(); const message = submittedMessage(clientMessageId); - const handedOff = useSessionStore - .getState() - .handoffCreatedAgentUserMessage("test-server", agentId, message); + const handedOff = timelineReplica.handoffSubmission(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 3f1ae6ffcf..579e7451f4 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -4,22 +4,10 @@ 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"; @@ -296,6 +284,18 @@ 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,74 +453,13 @@ 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, @@ -531,10 +470,12 @@ interface SessionStoreActions { older: "available" | "none" | "unchanged"; newer: boolean; synchronized: boolean; - acknowledgedClientMessageIds: string[]; + submissions: MessageSubmissionRecord[]; }, ) => void; + removeAgentTimeline: (serverId: string, agentId: string) => void; + // Initializing agents setInitializingAgents: ( serverId: string, @@ -931,354 +872,6 @@ 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) => { @@ -1321,107 +914,58 @@ 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; - 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, + 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, ); - 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); + 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; return { ...prev, @@ -1444,6 +988,37 @@ 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 new file mode 100644 index 0000000000..3ffd41ba74 --- /dev/null +++ b/packages/app/src/timeline/__fixtures__/legacy-unverifiable-timeline.json @@ -0,0 +1,62 @@ +{ + "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 new file mode 100644 index 0000000000..e419c1f2c8 --- /dev/null +++ b/packages/app/src/timeline/__fixtures__/mixed-unpositioned-message.json @@ -0,0 +1,44 @@ +{ + "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 deleted file mode 100644 index 1ae97e0281..0000000000 --- a/packages/app/src/timeline/fetch-agent-timeline-once.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index e0d67b4897..0000000000 --- a/packages/app/src/timeline/fetch-agent-timeline-once.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 new file mode 100644 index 0000000000..0abce24fe0 --- /dev/null +++ b/packages/app/src/timeline/replica.contract.test.ts @@ -0,0 +1,2171 @@ +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 53d4710347..c43548a385 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -1,28 +1,90 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { + createTimelineReplica, + type TimelineReplicaStorage, + type TimelineResponsePayload, + type TimelineReplica, +} from "./replica"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { CachedTimeline } from "@/runtime/replica-cache"; import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { - createTimelineReplica, createViewedTimelineOwner, - type TimelineReplicaStorage, + type TimelinePageResult, 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): StreamItem { +function item(id: string, text: string, seq: number, messageId?: string): 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, @@ -36,7 +98,6 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { const replica = createTimelineReplica({ serverId: SERVER_ID, storage, - prepareAgent: async () => undefined, }); return createViewedTimelineOwner({ serverId: SERVER_ID, @@ -46,7 +107,6 @@ 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, @@ -55,21 +115,379 @@ 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; @@ -80,13 +498,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); @@ -101,11 +519,12 @@ 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.replaceVisibleAgentIds("test", [AGENT_ID]); + owner.registerVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => @@ -120,6 +539,7 @@ 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)], @@ -129,7 +549,7 @@ describe("viewed timeline persistence", () => { commitTimeline: () => undefined, }); - owner.replaceVisibleAgentIds("test", [AGENT_ID]); + owner.registerVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), @@ -173,18 +593,114 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); - it("reopens painted live mutations without persisting authoritative coverage", async () => { + it("retains the certified baseline across a close during live catch-up", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); - let durable: CachedTimeline | undefined = cachedTimeline(); + 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 pending: CachedTimeline | undefined; const storage: TimelineReplicaStorage = { + removeTimeline: () => undefined, readTimeline: async () => durable, commitTimeline: (_serverId, _agentId, timeline) => { pending = timeline; }, }; const first = createOwner(storage); - first.replaceVisibleAgentIds("test", [AGENT_ID]); + first.registerVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), @@ -211,7 +727,7 @@ describe("viewed timeline persistence", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); const reopened = createOwner(storage); - reopened.replaceVisibleAgentIds("test", [AGENT_ID]); + reopened.registerVisibleAgentIds("test", [AGENT_ID]); await expect .poll(() => selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), @@ -226,6 +742,74 @@ 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; @@ -233,12 +817,13 @@ describe("viewed timeline persistence", () => { release = resolve; }); const owner = createOwner({ + removeTimeline: () => undefined, readTimeline: () => read, commitTimeline: () => undefined, }); - owner.replaceVisibleAgentIds("test", [AGENT_ID]); - applySynced(AGENT_ID, 8); + owner.registerVisibleAgentIds("test", [AGENT_ID]); + owner.applyTimelineResponse(pageAt(8)); release(cachedTimeline()); await expect @@ -258,16 +843,15 @@ 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); - useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { - head: [item("live", "live", 5)], - }); + paintLive(replica, "live", 5); + const liveRows = useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID); release(cachedTimeline()); await preparation; @@ -275,9 +859,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([ - item("live", "live", 5), - ]); + expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID)).toEqual( + liveRows, + ); }); it("reconciles a live head that overlaps the cached canonical timeline", async () => { @@ -289,16 +873,23 @@ 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); - useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { - head: [item("cached", "cached", 4), item("live", "live", 5)], - }); + 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); release(cachedTimeline()); await preparation; @@ -306,7 +897,7 @@ describe("viewed timeline persistence", () => { expect([ ...(session?.agentStreamTail.get(AGENT_ID) ?? []), ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]); + ]).toEqual([cachedLive, live]); expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); }); @@ -319,22 +910,18 @@ 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); - useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, { - items: [item("live", "live", 5)], - head: [], - range: null, - older: "none", - newer: false, - synchronized: false, - acknowledgedClientMessageIds: [], - }); + paintLive(replica, "live", 5); + const live = useSessionStore + .getState() + .sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID) + ?.at(-1); release(cachedTimeline()); await preparation; @@ -342,18 +929,20 @@ describe("viewed timeline persistence", () => { expect([ ...(session?.agentStreamTail.get(AGENT_ID) ?? []), ...(session?.agentStreamHead.get(AGENT_ID) ?? []), - ]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]); + ]).toEqual([item("cached", "cached", 4), live]); 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", @@ -375,6 +964,7 @@ 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), }); @@ -409,12 +999,14 @@ 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), }); - applySynced(AGENT_ID, 8); - applySynced("agent-2", 3); + owner.applyTimelineResponse(pageAt(8)); + owner.applyTimelineResponse({ ...pageAt(3), agentId: "agent-2" }); + keys.length = 0; for (const [agentId, seq] of [ [AGENT_ID, 9], ["agent-2", 4], diff --git a/packages/app/src/timeline/replica.ts b/packages/app/src/timeline/replica.ts new file mode 100644 index 0000000000..b4ecb4ed43 --- /dev/null +++ b/packages/app/src/timeline/replica.ts @@ -0,0 +1,752 @@ +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 13897f8454..42f07860cf 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -4,6 +4,7 @@ import type { AgentTimelineItem, ToolCallDetail } from "@getpaseo/protocol/agent import { createUserMessage, hydrateStreamState, + rowStartSeq, type AgentToolCallItem, type StreamItem, } from "@/types/stream"; @@ -481,7 +482,12 @@ describe("processTimelineResponse", () => { it("replaces destructively when the same epoch rewinds behind the local cursor", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [makeAssistantItem("future history", "future-history")], + currentTail: [ + { + ...makeAssistantItem("future history", "future-history"), + timelineCursor: { epoch: "epoch-1", seq: 40 }, + }, + ], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, payload: { ...baseTimelineInput.payload, @@ -493,10 +499,37 @@ describe("processTimelineResponse", () => { }, }); - expect(getAssistantTexts(result.tail)).toEqual(["rewound history"]); + expect(getAssistantTexts([...result.tail, ...result.head])).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, @@ -958,6 +991,13 @@ 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, @@ -3038,7 +3078,7 @@ describe("processTimelineResponse", () => { })), ).toEqual([ { - id: "loaded-assistant", + id: "assistant-message", text: "projected response", messageId: "assistant-message", timelineCursor: { epoch: "epoch-1", seq: 500 }, @@ -3197,6 +3237,57 @@ 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"; @@ -4346,12 +4437,13 @@ describe("createAgentStreamReducerQueue", () => { let currentHead: StreamItem[] = []; const queue = createAgentStreamReducerQueue({ - getSnapshot: () => ({ - currentTail, - currentHead, - currentCursor: undefined, - }), - commit: (agentId, result) => { + apply: (agentId, events) => { + const result = processAgentStreamEvents({ + events, + currentTail, + currentHead, + currentCursor: undefined, + }); currentTail = result.tail; currentHead = result.head; commits.push({ @@ -4360,7 +4452,6 @@ describe("createAgentStreamReducerQueue", () => { cursorEndSeq: result.cursor?.endSeq ?? null, }); }, - handleSideEffects: () => {}, scheduleFlush: scheduler.schedule, cancelFlush: scheduler.cancel, }); @@ -4387,17 +4478,17 @@ describe("createAgentStreamReducerQueue", () => { const scheduler = createManualScheduler(); const commits: string[] = []; const queue = createAgentStreamReducerQueue({ - getSnapshot: () => ({ - currentTail: [], - currentHead: [], - currentCursor: undefined, - }), - commit: (agentId, result) => { + apply: (agentId, events) => { + const result = processAgentStreamEvents({ + events, + currentTail: [], + currentHead: [], + currentCursor: undefined, + }); commits.push( `${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`, ); }, - handleSideEffects: () => {}, scheduleFlush: scheduler.schedule, cancelFlush: scheduler.cancel, }); @@ -4416,17 +4507,17 @@ describe("createAgentStreamReducerQueue", () => { let currentCursor: TimelineCursor | undefined; const queue = createAgentStreamReducerQueue({ - getSnapshot: () => ({ - currentTail, - currentHead, - currentCursor, - }), - commit: (_agentId, result) => { + apply: (_agentId, events) => { + const result = processAgentStreamEvents({ + events, + currentTail, + currentHead, + currentCursor, + }); currentTail = result.tail; currentHead = result.head; currentCursor = result.cursor ?? undefined; }, - handleSideEffects: () => {}, scheduleFlush: scheduler.schedule, cancelFlush: scheduler.cancel, }); @@ -4465,17 +4556,17 @@ describe("createAgentStreamReducerQueue", () => { const scheduler = createManualScheduler(); const commits: string[] = []; const queue = createAgentStreamReducerQueue({ - getSnapshot: () => ({ - currentTail: [], - currentHead: [], - currentCursor: undefined, - }), - commit: (agentId, result) => { + apply: (agentId, events) => { + const result = processAgentStreamEvents({ + events, + currentTail: [], + currentHead: [], + currentCursor: undefined, + }); commits.push( `${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`, ); }, - handleSideEffects: () => {}, scheduleFlush: scheduler.schedule, cancelFlush: scheduler.cancel, }); @@ -4487,3 +4578,706 @@ 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 b04ad00f02..cb7a2a663b 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1,16 +1,25 @@ +import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; -import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; -import type { AssistantMessageItem, StreamItem, TodoEntry } from "@/types/stream"; +import type { + AssistantMessageItem, + StreamItem, + SourcedPosition, + ThoughtItem, + TodoEntry, + UserMessageItem, +} 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"; @@ -41,12 +50,14 @@ export interface TimelineCursor { // --------------------------------------------------------------------------- export type TimelineReducerSideEffect = - | { type: "catch_up"; cursor: { epoch: string; endSeq: number } } + // `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: "flush_pending_updates" }; export interface AgentStreamReducerSideEffect { type: "catch_up"; cursor: { epoch: string; endSeq: number }; + observedSeq?: number; } // --------------------------------------------------------------------------- @@ -136,6 +147,7 @@ interface TimelineResponseEntry { } export interface ProcessTimelineResponseInput { + replaceTail?: boolean; payload: { agentId: string; direction: TimelineDirection; @@ -182,6 +194,12 @@ interface TimelineUnit { timestamp: Date; } +interface HydratedTimelineEvent { + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: SourcedPosition; +} + interface TimelinePathResult { tail: StreamItem[]; head: StreamItem[]; @@ -306,6 +324,7 @@ function classifySessionTimelineSeq({ function deriveBootstrapTailTimelinePolicy({ direction, reset, + replaceTail, epoch, endCursor, isInitializing, @@ -313,6 +332,7 @@ function deriveBootstrapTailTimelinePolicy({ }: { direction: TimelineDirection; reset: boolean; + replaceTail: boolean; epoch: string; endCursor: { seq: number } | null; isInitializing: boolean; @@ -321,7 +341,7 @@ function deriveBootstrapTailTimelinePolicy({ replace: boolean; catchUpCursor: { epoch: string; endSeq: number } | null; } { - if (reset) { + if (reset || (replaceTail && direction === "tail")) { return { replace: true, catchUpCursor: null }; } @@ -388,11 +408,7 @@ function mergeTimelineWindow(args: { currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; - toHydratedEvents: (units: TimelineUnit[]) => Array<{ - event: AgentStreamEventPayload; - timestamp: Date; - timelineCursor: { epoch: string; seq: number }; - }>; + toHydratedEvents: (units: TimelineUnit[]) => HydratedTimelineEvent[]; }): TimelinePathResult { const { timelineUnits, payload, currentTail, currentHead, currentCursor, toHydratedEvents } = args; @@ -444,9 +460,9 @@ function mergeTimelineWindow(args: { const tail = [...reconciled.tail, ...reconciled.page] .map((item, order) => ({ item, order })) .sort((left, right) => { - 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; + const leftStart = rowStartSeq(left.item) ?? Number.POSITIVE_INFINITY; + const rightStart = rowStartSeq(right.item) ?? Number.POSITIVE_INFINITY; + return leftStart - rightStart || left.order - right.order; }) .map(({ item }) => item); const cursor = mergeTimelineCoverage(currentCursor, { @@ -501,17 +517,58 @@ 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[], - ) => Array<{ event: AgentStreamEventPayload; timestamp: Date }>; + toHydratedEvents: (units: TimelineUnit[]) => HydratedTimelineEvent[]; }): TimelinePathResult { const { timelineUnits, @@ -519,6 +576,7 @@ function applyTimelineReplacePath(args: { bootstrapPolicy, currentTail, currentHead, + currentCursor, sendingClientMessageIds, preserveContinuity, toHydratedEvents, @@ -534,7 +592,7 @@ function applyTimelineReplacePath(args: { preserveContinuity, canonicalCoverage: { epoch: payload.epoch, - endSeq: payload.endCursor?.seq ?? null, + endSeq: resolveCanonicalCoverageEnd(payload, currentCursor), }, }); const cursor: TimelineCursor | null = @@ -698,14 +756,17 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea const identityMerge = mergeTimelineIdentityBoundary(olderTail, currentTail); if (identityMerge) return identityMerge; - if (olderLast?.kind !== "assistant_message" || currentFirst?.kind !== "assistant_message") { + if ( + olderLast?.kind !== "assistant_message" || + currentFirst?.kind !== "assistant_message" || + (olderLast.messageId !== undefined && + currentFirst.messageId !== undefined && + olderLast.messageId !== currentFirst.messageId) + ) { return [...olderTail, ...currentTail]; } - const mergedAssistant: AssistantMessageItem = { - ...currentFirst, - text: `${olderLast.text}${currentFirst.text}`, - }; + const mergedAssistant: AssistantMessageItem = joinTextRows(olderLast, currentFirst); if (mergedAssistant.messageId === undefined && olderLast.messageId !== undefined) { mergedAssistant.messageId = olderLast.messageId; } @@ -757,161 +818,372 @@ function mergeOlderTimelinePage(input: { return [...retainedBefore, ...mergePrependedCanonicalTail(input.page, currentAtOrAfterPage)]; } -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; +// --------------------------------------------------------------------------- +// 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 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, +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, ) - ) { - return { tail: params.tail, head: params.head, reconciled: false }; + ); +} + +// 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 candidates.length === 1 ? candidates[0]! : []; +} - 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; - }; - - 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 }; +// 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 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, +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 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, timestamp: unit.timestamp, - 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; - }; - - if (headMatch) { - return { - tail: removeBlockGroup(params.tail), - head: replaceMatch(params.head, headMatch.index), - reconciled: true, - }; + })) { + 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 }; +} + +// 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: [] }; + } + 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 { - tail: replaceMatch(params.tail, match.index), - head: removeBlockGroup(params.head), - reconciled: true, + lanes: spliceLanes(removed, index, 0, [message]), + acknowledgedClientMessageIds: message.clientMessageId ? [message.clientMessageId] : [], }; } -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 }; +// 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 }]); } + return insertUnitInOrder(lanes, unit, epoch); +} - 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", +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: [], }; - 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 }; + } + if (unit.event.type === "timeline" && unit.event.item.type === "user_message") { + return applyUserUnit(lanes, unit, epoch); + } + return { lanes: applyOtherUnit(lanes, unit, epoch), acknowledgedClientMessageIds: [] }; } function reconcileOverlappingProjectedStreamItems(params: { @@ -921,109 +1193,22 @@ function reconcileOverlappingProjectedStreamItems(params: { epoch: string; currentEndSeq: number | undefined; }): { tail: StreamItem[]; head: StreamItem[]; reconciledUnits: Set } { - let tail = params.tail; - let head = params.head; + let lanes: OrderedLanes = { + rows: [...params.tail, ...params.head], + boundary: params.tail.length, + }; const reconciledUnits = new Set(); - if (params.currentEndSeq === undefined) return { tail, head, reconciledUnits }; - for (const unit of params.units) { - 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); + 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); } - 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: applied.tail, - head: applied.head, - acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], + tail: lanes.rows.slice(0, lanes.boundary), + head: lanes.rows.slice(lanes.boundary), + reconciledUnits, }; } @@ -1034,37 +1219,22 @@ function applyAcceptedForwardTimelineUnits(params: { currentHead: StreamItem[]; currentEndSeq: number | undefined; }): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { - 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; - + let lanes: OrderedLanes = { + rows: [...params.currentTail, ...params.currentHead], + boundary: params.currentTail.length, + }; + const acknowledged = new Set(); for (const unit of params.units) { - if (reconciled.reconciledUnits.has(unit)) continue; - const applied = applyCanonicalForwardUnit({ - tail, - head, - unit, - epoch: params.epoch, - }); - tail = applied.tail; - head = applied.head; + const applied = applyForwardUnit(lanes, unit, params.epoch, params.currentEndSeq); + lanes = applied.lanes; + for (const clientMessageId of applied.acknowledgedClientMessageIds) { + acknowledged.add(clientMessageId); + } } - return { - tail, - head, - acknowledgedClientMessageIds: deriveCanonicalAcknowledgements({ - units: params.units, - epoch: params.epoch, - currentTail: params.currentTail, - currentHead: params.currentHead, - }), + tail: lanes.rows.slice(0, lanes.boundary), + head: lanes.rows.slice(lanes.boundary), + acknowledgedClientMessageIds: [...acknowledged], }; } @@ -1074,14 +1244,15 @@ function deriveCanonicalAcknowledgements(params: { currentTail: StreamItem[]; currentHead: StreamItem[]; }): string[] { - let tail = params.currentTail; - let head = params.currentHead; + let lanes: OrderedLanes = { + rows: [...params.currentTail, ...params.currentHead], + boundary: params.currentTail.length, + }; const acknowledged = new Set(); for (const unit of params.units) { if (unit.event.type !== "timeline" || unit.event.item.type !== "user_message") continue; - const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch }); - tail = applied.tail; - head = applied.head; + const applied = applyUserUnit(lanes, unit, params.epoch); + lanes = applied.lanes; for (const clientMessageId of applied.acknowledgedClientMessageIds) { acknowledged.add(clientMessageId); } @@ -1155,10 +1326,10 @@ function applyAcceptedTimelinePage(input: { }); } const olderTail = hydrateStreamState( - acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ - event, - timestamp, - timelineCursor: { epoch: payload.epoch, seq: seqEnd }, + acceptedUnits.map((unit) => ({ + event: unit.event, + timestamp: unit.timestamp, + timelineCursor: unitPosition(unit, payload.epoch), })), { source: "canonical", @@ -1259,7 +1430,6 @@ export function processTimelineResponse( currentCursor, isInitializing, hasActiveInitDeferred, - initRequestDirection, sendingClientMessageIds, } = input; @@ -1274,9 +1444,7 @@ export function processTimelineResponse( cursor: currentCursor, cursorChanged: false, older: "unchanged", - initResolution: hasActiveInitDeferred ? "reject" : null, - clearInitializing: isInitializing, - error: payload.error, + ...processTimelineResponseCompletion(input), sideEffects: [], acknowledgedClientMessageIds: [], }; @@ -1301,23 +1469,18 @@ export function processTimelineResponse( timestamp: new Date(entry.timestamp), })); - 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 }, + const toHydratedEvents = (units: TimelineUnit[]): HydratedTimelineEvent[] => + units.map((unit) => ({ + event: unit.event, + timestamp: unit.timestamp, + timelineCursor: unitPosition(unit, payload.epoch), })); // ------------------------------------------------------------------ // Derive bootstrap policy (replace vs incremental) // ------------------------------------------------------------------ const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({ + replaceTail: input.replaceTail === true, direction: payload.direction, reset: payload.reset, epoch: payload.epoch, @@ -1372,6 +1535,7 @@ export function processTimelineResponse( : bootstrapPolicy, currentTail, currentHead, + currentCursor, sendingClientMessageIds, preserveContinuity: shouldPreserveReplacementContinuity({ isResumeReplacement, @@ -1410,20 +1574,6 @@ 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 { @@ -1433,9 +1583,7 @@ export function processTimelineResponse( cursor: nextCursor, cursorChanged, older: timelineResult.older, - initResolution, - clearInitializing, - error: null, + ...processTimelineResponseCompletion(input), sideEffects, acknowledgedClientMessageIds: timelineResult.acknowledgedClientMessageIds, }; @@ -1502,13 +1650,7 @@ export interface AgentStreamReducerQueue { } export interface CreateAgentStreamReducerQueueInput { - getSnapshot: (agentId: string) => AgentStreamReducerSnapshot; - commit: ( - agentId: string, - result: ProcessAgentStreamEventOutput, - events: AgentStreamReducerEvent[], - ) => void; - handleSideEffects: (agentId: string, sideEffects: AgentStreamReducerSideEffect[]) => void; + apply: (agentId: string, events: AgentStreamReducerEvent[]) => void; scheduleFlush: (callback: () => void) => number; cancelFlush: (id: number) => void; } @@ -1568,6 +1710,7 @@ function processTimelineSequencingGate(input: { { type: "catch_up", cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq }, + observedSeq: seq, }, ] : [], @@ -1769,15 +1912,7 @@ export function createAgentStreamReducerQueue( cancelScheduledFlush(); } - const result = processAgentStreamEvents({ - events, - ...input.getSnapshot(agentId), - }); - - input.commit(agentId, result, events); - if (result.sideEffects.length > 0) { - input.handleSideEffects(agentId, result.sideEffects); - } + input.apply(agentId, events); }; const flush = () => { @@ -1820,13 +1955,6 @@ export function createAgentStreamReducerQueue( }; } -interface StreamStatePatch { - tail?: StreamItem[]; - head?: StreamItem[]; - acknowledgedClientMessageIds?: readonly string[]; - taskSnapshot?: TodoEntry[]; -} - export function deriveAgentStreamTurnLiveness( events: readonly AgentStreamReducerEvent[], ): TurnLivenessTransition[] { @@ -1848,17 +1976,6 @@ 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; @@ -1878,7 +1995,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. -function scheduleAgentStreamReducerFlush(callback: () => void): number { +export function scheduleAgentStreamReducerFlush(callback: () => void): number { const id = nextScheduledReducerFlushId; nextScheduledReducerFlushId += 1; @@ -1898,7 +2015,7 @@ function scheduleAgentStreamReducerFlush(callback: () => void): number { return id; } -function cancelAgentStreamReducerFlush(id: number) { +export function cancelAgentStreamReducerFlush(id: number) { const handle = scheduledReducerFlushes.get(id); if (!handle) { return; @@ -1906,80 +2023,3 @@ 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 8c7c156e6f..e38430ffdb 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -1,10 +1,6 @@ import { expect, test, vi } from "vitest"; import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan"; -import { - consumeForcedTimelineTailReplacement, - createViewedTimelineSync, - type TimelineResponsePayload, -} from "./viewed-timeline-sync"; +import { createViewedTimelineSync } from "./viewed-timeline-sync"; interface Deferred { promise: Promise; @@ -31,7 +27,7 @@ interface MembershipRequest { interface TimelineFetch { agentId: string; request: ProjectedTimelineForwardFetchPlan; - respond(input: { hasNewer: boolean; seq?: number }): void; + respond(input: { hasNewer: boolean; seq?: number | null }): void; fail(message: string): void; } @@ -39,7 +35,6 @@ 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, @@ -71,7 +66,7 @@ class TimelineWorld { respond: ({ hasNewer, seq = 1 }) => result.resolve({ hasNewer, - endCursor: { epoch: `epoch-${agentId}`, seq }, + endCursor: seq === null ? null : { epoch: `epoch-${agentId}`, seq }, }), fail: (message) => result.reject(new Error(message)), }); @@ -79,16 +74,11 @@ class TimelineWorld { return result.promise; }, fetchLatestTail: async (agentId) => { - this.forcedTimelineTailReplacements.add(agentId); - try { - return await this.fetchTimeline(agentId, { - direction: "tail", - limit: 40, - projection: "projected", - }); - } finally { - this.forcedTimelineTailReplacements.delete(agentId); - } + return await this.fetchTimeline(agentId, { + direction: "tail", + limit: 40, + projection: "projected", + }); }, reportError: (error) => { this.errors.push(error instanceof Error ? error.message : String(error)); @@ -130,10 +120,6 @@ 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); @@ -152,7 +138,10 @@ class TimelineWorld { agentId, request, respond: ({ hasNewer, seq = 1 }) => - result.resolve({ hasNewer, endCursor: { epoch: `epoch-${agentId}`, seq } }), + result.resolve({ + hasNewer, + endCursor: seq === null ? null : { epoch: `epoch-${agentId}`, seq }, + }), fail: (message) => result.reject(new Error(message)), }); this.releaseFetchWaiters(); @@ -222,7 +211,7 @@ class TimelineWorld { test("uses a tail fetch when an agent becomes visible", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -235,7 +224,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const cacheRequest = await world.nextCacheRequest(); @@ -256,11 +245,22 @@ 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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -288,63 +288,168 @@ 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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const tail = await world.nextFetch("agent-a"); - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 }); + // 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.expectNoPendingFetch(); - tail.respond({ hasNewer: false }); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + tail.respond({ hasNewer: false, seq: 10 }); + // 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: 9 }, + cursor: { epoch: "epoch-agent-a", seq: 10 }, limit: 40, projection: "projected", }); - recovery.respond({ hasNewer: false }); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 12 }); + recovery.respond({ hasNewer: false, seq: 12 }); 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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("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.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]); + world.sync.registerVisibleAgentIds("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"); @@ -366,7 +471,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.replaceVisibleAgentIds("workspace", ["agent-b", "agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b", "agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -383,7 +488,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.replaceVisibleAgentIds("workspace", [ + world.sync.registerVisibleAgentIds("workspace", [ "agent-a", "agent-b", "agent-c", @@ -391,7 +496,7 @@ test("an eviction during acknowledgement never catches up the stale hot set", as "agent-e", ]); const staleMembership = await world.nextMembership(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-f"]); staleMembership.succeed(); const currentMembership = await world.nextMembership(); currentMembership.succeed(); @@ -412,7 +517,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const firstMembership = await world.nextMembership(); firstMembership.succeed(); const stalePage = await world.nextFetch("agent-a"); @@ -434,8 +539,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.replaceVisibleAgentIds("workspace", ["agent-a"]); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); world.sync.setConnected(true); const membership = await world.nextMembership(); @@ -449,8 +554,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.replaceVisibleAgentIds("left-route", ["agent-a"]); - world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]); + world.sync.registerVisibleAgentIds("left-route", ["agent-a"]); + world.sync.registerVisibleAgentIds("right-route", ["agent-a", "agent-b"]); world.sync.setConnected(true); const combined = await world.nextMembership(); combined.succeed(); @@ -461,19 +566,37 @@ test("overlapping sources deduplicate membership and retain hidden hot agents", agentA.respond({ hasNewer: false }); agentB.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("left-route", []); + world.sync.registerVisibleAgentIds("left-route", []); world.expectNoPendingMembership(); - world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]); + world.sync.registerVisibleAgentIds("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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const failed = await world.nextFetch("agent-a"); @@ -497,7 +620,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -526,7 +649,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -546,7 +669,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -554,7 +677,7 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async failed.fail("timeline unavailable"); await world.nextRetry(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -564,7 +687,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); @@ -583,14 +706,15 @@ 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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("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.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 12); const gapPage = await world.nextFetch("agent-a"); gapPage.respond({ hasNewer: true, seq: 15 }); const finalPage = await world.nextFetch("agent-a"); @@ -615,7 +739,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); membership.succeed(); const initial = await world.nextFetch("agent-a"); @@ -634,7 +758,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]); @@ -658,7 +782,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const first = await world.nextMembership(); first.fail("subscription unavailable"); @@ -686,13 +810,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); await world.nextRetry(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -702,13 +826,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const agentAMembership = await world.nextMembership(); agentAMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); const agentBMembership = await world.nextMembership(); agentBMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -727,12 +851,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); const staleRetry = await world.nextRetry(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); const current = await world.nextMembership(); staleRetry(); current.succeed(); @@ -750,7 +874,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); const disconnectedRetry = await world.nextRetry(); @@ -770,13 +894,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const initialMembership = await world.nextMembership(); initialMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); const expandedMembership = await world.nextMembership(); expandedMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -784,7 +908,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -794,7 +918,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.replaceVisibleAgentIds("workspace", [agentId]); + world.sync.registerVisibleAgentIds("workspace", [agentId]); const membership = await world.nextMembership(); membership.succeed(); const catchUp = await world.nextFetch(agentId); @@ -802,22 +926,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.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); + world.sync.registerVisibleAgentIds("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.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const restoreEvicted = await world.nextMembership(); restoreEvicted.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); @@ -829,7 +953,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.replaceVisibleAgentIds("workspace", visible); + world.sync.registerVisibleAgentIds("workspace", visible); const membership = await world.nextMembership(); membership.succeed(); const catchUps = await Promise.all(visible.map((agentId) => world.nextFetch(agentId))); @@ -837,7 +961,7 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn expect(membership.agentIds).toEqual(visible); - world.sync.replaceVisibleAgentIds("workspace", visible.slice(0, 5)); + world.sync.registerVisibleAgentIds("workspace", visible.slice(0, 5)); const hiddenEviction = await world.nextMembership(); hiddenEviction.succeed(); expect(hiddenEviction.agentIds).toEqual(visible.slice(0, 5)); @@ -846,13 +970,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const agentAMembership = await world.nextMembership(); agentAMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-b"]); const agentBMembership = await world.nextMembership(); agentBMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -869,7 +993,7 @@ test("disconnect clears hidden hot agents before reconnecting the visible set", expect(restored.agentIds).toEqual(["agent-b"]); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); const reopened = await world.nextMembership(); reopened.succeed(); const reopenedCatchUp = await world.nextFetch("agent-a"); @@ -880,7 +1004,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("workspace", ["agent-a"]); world.sync.setConnected(true); world.expectNoPendingMembership(); @@ -888,7 +1012,8 @@ 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.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 12); const recovery = await world.nextFetch("agent-a"); recovery.respond({ hasNewer: false }); @@ -904,17 +1029,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("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.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.sync.registerVisibleAgentIds("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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("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")); @@ -930,7 +1055,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.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.registerVisibleAgentIds("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 323676739b..f1e32c9d3d 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -1,310 +1,23 @@ +import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { planTimelineCatchUpAfter, planTimelineResumeFetch, type ProjectedTimelineForwardFetchPlan, } from "./timeline-sync-plan"; -import type { CachedTimeline } from "@/runtime/replica-cache"; import { - 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, + createAgentStreamReducerQueue, + scheduleAgentStreamReducerFlush, + cancelAgentStreamReducerFlush, type AgentStreamReducerEvent, - type ProcessTimelineResponseOutput, - processTimelineResponse, } from "./session-stream-reducers"; -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 beforeHead = before?.agentStreamHead.get(input.agentId); - await input.prepareAgent(input.agentId); - const stored = await input.storage.readTimeline(input.serverId, 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 (currentHead !== beforeHead) return undefined; - 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 = stored.range - ? replaceWithCanonicalStream({ - canonical: stored.items, - previousTail: [], - previousHead: liveItems, - sendingClientMessageIds: getSendingClientMessageIds( - session?.messageSubmissions.get(input.agentId), - ), - preserveContinuity: true, - canonicalCoverage: stored.range, - }) - : { tail: stored.items, head: liveItems, acknowledgedClientMessageIds: [] }; - 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); -} +import type { TimelineReplica, TimelineResponsePayload, TimelinePageRequest } from "./replica"; +import type { AgentRemovalReason } from "@/utils/agent-directory-sync"; 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; @@ -323,8 +36,13 @@ 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 { - replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void; + registerVisibleAgentIds(sourceId: string, agentIds: string[]): ViewedTimelineDemandDeclaration; subscribe(listener: () => void): () => void; getAgentTimelineStatus(agentId: string): ViewedTimelineStatus; getAgentTimelineError(agentId: string): string | null; @@ -335,19 +53,32 @@ 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 }): void; + recoverGap( + agentId: string, + cursor: { epoch: string; endSeq: number }, + observedSeq?: number, + ): void; + beginAgentLifetime(agentId: string): void; + endAgentLifetime(agentId: string): void; dispose(): void; } export type ViewedTimelineOwnerPorts = Omit< ViewedTimelineSyncPorts, - "prepare" | "replaceDemandedAgentIds" + "prepare" | "replaceDemandedAgentIds" | "readCursor" >; 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: { @@ -355,33 +86,78 @@ 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({ - ...input.ports, + 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); + }, prepare: (agentId) => input.replica.prepare(agentId), - readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId), + readCursor: (agentId) => input.replica.readCursor(agentId), replaceDemandedAgentIds: input.replaceDemandedAgentIds, }); - 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), + const streamQueue = createAgentStreamReducerQueue({ + apply: (agentId, events) => input.replica.applyEvents(agentId, events, sync.recoverGap), + scheduleFlush: scheduleAgentStreamReducerFlush, + cancelFlush: cancelAgentStreamReducerFlush, }); + 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, - 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); + attachTransport(ports) { + sync.setConnected(false); + transport = ports; + sync.setDeliveryMode(ports.initialDeliveryMode); + return () => { + if (transport !== ports) return; + sync.setConnected(false); + transport = undefined; + }; }, + 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); }, @@ -409,6 +185,11 @@ interface CatchUpState { retryDelayMs?: number; } +interface VisibleAgentSource { + declaration: object; + agentIds: string[]; +} + const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => { if (previousDelayMs == null) { return RETRY_DELAY_MS; @@ -416,40 +197,6 @@ 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(); } @@ -459,12 +206,13 @@ 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(); - // Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked. - // Acknowledgement and tail completion are the only drain points. - const pendingCatchUps = 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(); const visibilityCatchUpPending = new Set(); const visibilityCatchUpErrors = new Map(); // User-initiated retries only. Background retries stay silent; a retry the user asked for @@ -487,7 +235,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed let cancelMembershipRetry: (() => void) | null = null; let recentlyViewedAgentIds: string[] = []; - const visibleAgentIds = () => normalizeAgentIds([...sources.values()].flat()); + const visibleAgentIds = () => + normalizeAgentIds([...sources.values()].flatMap((source) => source.agentIds)); const selectHotAgentIds = (visible: string[]) => { const visibleSet = new Set(visible); @@ -541,11 +290,36 @@ 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); - pendingCatchUps.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; }; const fetchUntilCurrent = async ( @@ -557,32 +331,41 @@ 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); - catchUps.set(agentId, { generation, status: "complete" }); - setVisibilityCatchUpReady(agentId); + if (!ownsCatchUp(agentId, generation)) return; + } else { + await fetchUntilCurrent( + agentId, + generation, + planTimelineCatchUpAfter(page.endCursor), + false, + ); return; } - await fetchUntilCurrent( - agentId, - generation, - planTimelineCatchUpAfter(page.endCursor), - false, - ); - return; - } - if (page.hasNewer) { + } else 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 }); + const owed = owedAfter(agentId, cursorBefore, observedBefore); + if (owed) { + await fetchUntilCurrent(agentId, generation, owed, false); return; } + catchUps.set(agentId, { generation, status: "complete" }); setVisibilityCatchUpReady(agentId); } catch (error) { if (catchUps.get(agentId)?.generation === generation) { @@ -611,69 +394,43 @@ 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); - const pending = pendingCatchUps.get(agentId); - startCatchUp(agentId, { request: pending, supersede: Boolean(pending) }); + startCatchUp(agentId); }); cacheLoads.set(agentId, load); }; - 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; - } + // 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; 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); - const decision = decideCatchUp({ current, request: nextRequest, supersede }); - if (decision === "keep-and-park") { - pendingCatchUps.set(agentId, nextRequest); - return; - } - if (decision === "keep") { - return; - } + if (current?.status === "running") return; + if (current?.status === "complete" && !supersede) 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: nextRequest, - retryDelayMs, - }); - pendingCatchUps.delete(agentId); - void fetchUntilCurrent( - agentId, - generation, - nextRequest, - request === undefined && nextRequest.direction === "after", - ); + 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); }; const startAcknowledgedCatchUps = () => { - for (const agentId of acknowledged) { - const pendingCatchUp = pendingCatchUps.get(agentId); - startCatchUp(agentId, { - request: pendingCatchUp, - supersede: Boolean(pendingCatchUp), - }); - } + for (const agentId of acknowledged) startCatchUp(agentId); }; const reconcileLatestMembership = async (): Promise => { @@ -753,7 +510,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed notifyListeners(); if (catchUp?.status === "error") { catchUp.cancelRetry?.(); - startCatchUp(agentId, { request: catchUp.request, supersede: true }); + startCatchUp(agentId, { supersede: true }); return; } cancelMembershipRetry?.(); @@ -839,11 +596,26 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed getAgentTimelineError(agentId) { return visibilityCatchUpErrors.get(agentId) ?? null; }, - replaceVisibleAgentIds(sourceId, agentIds) { - const normalized = normalizeAgentIds(agentIds); - if (normalized.length === 0) sources.delete(sourceId); - else sources.set(sourceId, normalized); + registerVisibleAgentIds(sourceId, agentIds) { + const declaration = {}; + sources.set(sourceId, { + declaration, + agentIds: normalizeAgentIds(agentIds), + }); 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; @@ -895,12 +667,30 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (deliveryMode === "selective" && connected) void reconcileMembership(); else if (connected) startAcknowledgedCatchUps(); }, - recoverGap(agentId, cursor) { + recoverGap(agentId, cursor, observedSeq) { if (!isDesired(agentId)) return; - startCatchUp(agentId, { - request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }), - supersede: true, - }); + 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(); + } }, dispose() { disposed = true; @@ -917,6 +707,7 @@ 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 cd05fd0826..a73f52c2d6 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -8,6 +8,7 @@ import { handoffCreatedAgentUserMessageToStream, hydrateStreamState, mergeToolCallDetail, + mergeCanonicalText, reduceStreamUpdate, streamTimelineItemIdentity, type AgentToolCallItem, @@ -23,6 +24,122 @@ 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 4ebd0972db..4039ac0d7b 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -97,6 +97,7 @@ export interface UserMessageItem { messageId?: string; turnId?: string; timelineCursor?: TimelinePosition; + source?: RowSource; text: string; timestamp: Date; images?: UserMessageImageAttachment[]; @@ -109,6 +110,7 @@ export interface UserMessageInput { messageId?: string; turnId?: string; timelineCursor?: TimelinePosition; + source?: RowSource; text: string; timestamp: Date; images?: UserMessageImageAttachment[]; @@ -127,6 +129,7 @@ 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 } : {}), @@ -261,12 +264,14 @@ 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 && @@ -412,13 +417,19 @@ 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: { epoch: string; endSeq: number | null }; + canonicalCoverage: CanonicalCoverage; } export interface CanonicalStreamReplacementResult { @@ -427,13 +438,33 @@ export interface CanonicalStreamReplacementResult { acknowledgedClientMessageIds: string[]; } -function isAfterCanonicalCoverage( - position: TimelinePosition, - coverage: CanonicalStreamReplacementInput["canonicalCoverage"], +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, ): boolean { - return ( - position.epoch === coverage.epoch && - (coverage.endSeq === null || position.seq > coverage.endSeq) + 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), ); } @@ -543,29 +574,53 @@ 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: CanonicalStreamReplacementInput["canonicalCoverage"], + canonicalCoverage: CanonicalCoverage, ): CanonicalStreamReplacementResult { - 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 retainedHead = currentHead.filter((item) => + retainsAcrossReplacement(item, { + canonical, + tail, + preserveContinuity, + sendingClientMessageIds, + canonicalCoverage, + }), + ); const { tail: reconciledTail, head: unreconciledHead } = reconcileReplacementHeadAgainstTail( tail, retainedHead, @@ -584,7 +639,29 @@ 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 && @@ -592,41 +669,29 @@ function preserveReplacementHead( 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 && - 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), - ]; + 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 + ); + } return { - tail: reconciledTail.slice(0, -1), - head, - acknowledgedClientMessageIds: [], + ...liveAssistant, + text: extendsText ? liveAssistant.text : `${tailAssistant.text}${liveAssistant.text}`, }; } - 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: [], - }; + // 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; } export function replaceWithCanonicalStream( @@ -673,24 +738,29 @@ export function replaceWithCanonicalStream( nextTail.push(item); } - 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) + 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) ) { - retainedTailMessages.push(local); + retainedTailRows.push(item); } } - nextHead = [...retainedTailMessages, ...nextHead]; + nextHead = [...retainedTailRows, ...nextHead]; const replacement = preserveReplacementHead( + input.canonical, nextTail, nextHead, input.preserveContinuity, @@ -713,6 +783,7 @@ export interface AssistantMessageItem { timestamp: Date; blockGroupId?: string; blockIndex?: number; + source?: RowSource; } export interface TimelinePosition { @@ -720,6 +791,25 @@ 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 { @@ -730,6 +820,159 @@ 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"; @@ -762,6 +1005,7 @@ export interface ToolCallItem { kind: "tool_call"; id: string; timelineCursor?: TimelinePosition; + source?: RowSource; turnId?: string; timestamp: Date; payload: ToolCallPayload; @@ -782,6 +1026,7 @@ export interface NotificationItem { sourceType: "error" | "notification"; id: string; timelineCursor?: TimelinePosition; + source?: RowSource; turnId?: string; timestamp: Date; level: NotificationLevel; @@ -792,6 +1037,7 @@ export interface CompactionItem { kind: "compaction"; id: string; timelineCursor?: TimelinePosition; + source?: RowSource; turnId?: string; timestamp: Date; status: "loading" | "completed"; @@ -803,6 +1049,7 @@ export interface PluginTimelineStreamItem { kind: "plugin"; id: string; timelineCursor?: TimelinePosition; + source?: RowSource; turnId?: string; timestamp: Date; pluginId: string; @@ -828,6 +1075,7 @@ export interface TodoListItem { kind: "todo_list"; id: string; timelineCursor?: TimelinePosition; + source?: RowSource; turnId?: string; timestamp: Date; provider: AgentProvider; @@ -840,7 +1088,7 @@ export type StreamUpdateSource = "live" | "canonical"; interface StreamUpdateOptions { source?: StreamUpdateSource; reservedItemIds?: ReadonlySet; - timelineCursor?: TimelinePosition; + timelineCursor?: SourcedPosition; } function isRecord(value: unknown): value is Record { @@ -901,7 +1149,7 @@ function appendUserMessage( id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp), clientMessageId, messageId, - timelineCursor, + ...positionedRow(timelineCursor), turnId, text: chunk, timestamp, @@ -916,7 +1164,7 @@ function appendAssistantMessage( source: StreamUpdateSource, messageId?: string, reservedItemIds?: ReadonlySet, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -929,13 +1177,7 @@ function appendAssistantMessage( last.kind === "assistant_message" && (messageId === undefined || last.messageId === messageId); if (shouldAppendToLast) { - const updated: AssistantMessageItem = { - ...last, - text: `${last.text}${chunk}`, - timestamp, - ...(timelineCursor ? { timelineCursor } : {}), - }; - return [...state.slice(0, -1), updated]; + return [...state.slice(0, -1), extendTextRow(last, chunk, timestamp, timelineCursor)]; } // A submitted user row can follow the streaming assistant during interrupt. @@ -947,13 +1189,11 @@ function appendAssistantMessage( secondLast?.kind === "assistant_message" && (messageId === undefined || secondLast.messageId === messageId) ) { - const updated: AssistantMessageItem = { - ...secondLast, - text: `${secondLast.text}${chunk}`, - timestamp, - ...(timelineCursor ? { timelineCursor } : {}), - }; - return [...state.slice(0, -2), updated, last]; + return [ + ...state.slice(0, -2), + extendTextRow(secondLast, chunk, timestamp, timelineCursor), + last, + ]; } if (!hasContent) { @@ -966,18 +1206,53 @@ function appendAssistantMessage( kind: "assistant_message", id: entryId, ...(messageId ? { messageId } : {}), - ...(timelineCursor ? { timelineCursor } : {}), + ...startedTextRow(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?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -986,14 +1261,10 @@ function appendThought( const last = state[state.length - 1]; if (last && last.kind === "thought") { - const updated: ThoughtItem = { - ...last, - ...(timelineCursor ? { timelineCursor } : {}), - text: `${last.text}${chunk}`, - timestamp, - status: "loading", - }; - return [...state.slice(0, -1), updated]; + return [ + ...state.slice(0, -1), + { ...extendTextRow(last, chunk, timestamp, timelineCursor), status: "loading" }, + ]; } if (!hasContent) { @@ -1004,7 +1275,7 @@ function appendThought( const item: ThoughtItem = { kind: "thought", id: createUniqueTimelineId(state, "thought", idSeed, timestamp), - ...(timelineCursor ? { timelineCursor } : {}), + ...startedTextRow(timelineCursor), text: chunk, timestamp, status: "loading", @@ -1156,7 +1427,7 @@ export function mergeAgentToolCallItem( existing: AgentToolCallItem, data: AgentToolCallData, timestamp: Date, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): AgentToolCallItem { const mergedStatus = mergeAgentToolCallStatus(existing.payload.data.status, data.status); const mergedError = @@ -1168,7 +1439,9 @@ export function mergeAgentToolCallItem( return { ...existing, - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, existing) } + : {}), timestamp, payload: { source: "agent", @@ -1189,7 +1462,7 @@ interface AppendAgentToolCallInput { data: AgentToolCallData; timestamp: Date; turnId?: string; - timelineCursor?: TimelinePosition; + timelineCursor?: SourcedPosition; } function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] { @@ -1225,7 +1498,9 @@ function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] { const item: ToolCallItem = { kind: "tool_call", id: `agent_tool_${identity}`, - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } + : {}), ...(turnId ? { turnId } : {}), timestamp, payload: { @@ -1254,7 +1529,7 @@ function appendPluginTimelineItem( state: StreamItem[], item: Extract, timestamp: Date, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { const identity = timelineItemIdentity(item); if (identity === null) return state; @@ -1267,14 +1542,22 @@ function appendPluginTimelineItem( version: item.version, data: item.data, timestamp, - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(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 }; + next[existingIndex] = { + ...nextItem, + id: existing.id, + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, existing) } + : {}), + }; return next; } @@ -1283,7 +1566,7 @@ function appendTodoList( provider: AgentProvider, items: TodoEntry[], timestamp: Date, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { const normalizedItems = items.map((item) => ({ text: item.text, @@ -1305,7 +1588,9 @@ function appendTodoList( const next = [...state]; next[previousIndex] = { ...previous, - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, previous) } + : {}), items: normalizedItems, timestamp, }; @@ -1324,7 +1609,9 @@ function appendTodoList( const next = [...state]; next[next.length - 1] = { ...lastItem, - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, lastItem) } + : {}), items: normalizedItems, activity: { type: "created", count: normalizedItems.length }, timestamp, @@ -1338,7 +1625,9 @@ function appendTodoList( next.push({ kind: "todo_list", id: createUniqueTimelineId(next, "todo", idSeed, timestamp), - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } + : {}), timestamp, provider, items: normalizedItems, @@ -1393,7 +1682,7 @@ function reduceTimelineToolCall( { type: "tool_call" } >, timestamp: Date, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { const normalizedToolName = item.name .trim() @@ -1464,7 +1753,7 @@ function reduceTimelineCompaction( { type: "compaction" } >, timestamp: Date, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { if (item.status === "completed") { const loadingIdx = state.findIndex((s) => s.kind === "compaction" && s.status === "loading"); @@ -1472,7 +1761,12 @@ function reduceTimelineCompaction( if (loadingIdx >= 0 && existing && existing.kind === "compaction") { const updated: CompactionItem = { ...existing, - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { + timelineCursor: anchorOf(timelineCursor), + source: sourceFor(timelineCursor, existing), + } + : {}), status: "completed", trigger: item.trigger ?? existing.trigger, preTokens: item.preTokens ?? existing.preTokens, @@ -1486,7 +1780,9 @@ function reduceTimelineCompaction( const compaction: CompactionItem = { kind: "compaction", id: createUniqueTimelineId(state, "compaction", item.status, timestamp), - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } + : {}), timestamp, status: item.status, trigger: item.trigger, @@ -1501,7 +1797,7 @@ function reduceTimelineEvent( timestamp: Date, source: StreamUpdateSource, reservedItemIds?: ReadonlySet, - timelineCursor?: TimelinePosition, + timelineCursor?: SourcedPosition, ): StreamItem[] { const item = event.item; switch (item.type) { @@ -1553,7 +1849,9 @@ function reduceTimelineEvent( kind: "notification", sourceType: "error", id: createUniqueTimelineId(state, "error", item.message ?? "", timestamp), - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } + : {}), timestamp, level: "error", message: item.message ?? "Unknown error", @@ -1565,7 +1863,9 @@ function reduceTimelineEvent( kind: "notification", sourceType: "notification", id: createUniqueTimelineId(state, "notification", item.message, timestamp), - ...(timelineCursor ? { timelineCursor } : {}), + ...(timelineCursor + ? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) } + : {}), timestamp, level: item.level, message: item.message, @@ -1674,7 +1974,7 @@ export function hydrateStreamState( events: Array<{ event: AgentStreamEventPayload; timestamp: Date; - timelineCursor?: TimelinePosition; + timelineCursor?: SourcedPosition; }>, options?: Pick, ): StreamItem[] { @@ -1828,28 +2128,39 @@ 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)}`; - const promotedItems = completedBlocks.map((block, offset) => ({ - ...activeItem, - id: createAssistantBlockId({ - groupId: blockGroupId, - blockIndex: firstBlockIndex + offset, - }), - blockGroupId, - blockIndex: firstBlockIndex + offset, - text: block, - })); + // 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 nextTail = flushHeadToTail(params.tail, promotedItems); - const liveItem: AssistantMessageItem = { - ...activeItem, - id: createAssistantBlockId({ - groupId: blockGroupId, - blockIndex: firstBlockIndex + completedBlocks.length, - }), - blockGroupId, - blockIndex: firstBlockIndex + completedBlocks.length, - text: liveBlock, - }; + const liveItem = blockRow(liveBlock, firstBlockIndex + completedBlocks.length); const nextHead = [ ...params.head.slice(0, assistantIndex), liveItem, @@ -1945,7 +2256,7 @@ function applyCanonicalUserMessageEvent(params: { head: StreamItem[]; event: AgentStreamEventPayload; timestamp: Date; - timelineCursor?: TimelinePosition; + timelineCursor?: SourcedPosition; unmatchedInsert?: "tail" | "head"; }): ApplyStreamEventResult | null { const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params; @@ -1961,7 +2272,7 @@ function applyCanonicalUserMessageEvent(params: { messageId: event.item.messageId, clientMessageId: event.item.clientMessageId, turnId: event.turnId, - timelineCursor, + ...positionedRow(timelineCursor), text: normalized.chunk, timestamp, }); @@ -2035,7 +2346,7 @@ export function applyStreamEvent(params: { event: AgentStreamEventPayload; timestamp: Date; source?: StreamUpdateSource; - timelineCursor?: TimelinePosition; + timelineCursor?: SourcedPosition; 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 c8e2479894..72158779a3 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -1,3 +1,4 @@ +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"; @@ -65,18 +66,26 @@ function permission(id: string): AgentPermissionRequest { return { id, provider: "codex", name: id, kind: "tool", title: id }; } -function beginPendingSubmission(serverId: string, agentId: string): string { +function beginPendingSubmission(serverId: string, agentId: string) { const clientMessageId = `client-${agentId}`; - useSessionStore.getState().beginAgentMessageSubmission( + const replica = createTimelineReplica({ 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; + return { clientMessageId, replica }; } describe("message submission authority", () => { @@ -85,9 +94,11 @@ 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).applyDelta({ + new AgentStoreProjection(serverId, (removedAgentId) => + useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), + ).applyDelta({ kind: "upsert", agent, project: createEntry(agent).project, @@ -108,28 +119,44 @@ 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); - 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: [], - }); + 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"), + }), + ); expect( useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0] ?.providerAcknowledged, ).toBe(false); - store.setAgentStreamState(serverId, agentId, { - acknowledgedClientMessageIds: [clientMessageId], - }); + 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, + ); expect( useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0] @@ -146,9 +173,9 @@ describe("replaceFetchedAgentDirectory", () => { store.initializeSession(serverId, null as unknown as DaemonClient); store.setInitializingAgents(serverId, new Map([["agent", true]])); - new AgentStoreProjection(serverId).replaceFetched([ - createEntry(createAgentPayload({ id: "agent" })), - ]); + new AgentStoreProjection(serverId, (removedAgentId) => + useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), + ).replaceFetched([createEntry(createAgentPayload({ id: "agent" }))]); expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get("agent")).toBe( true, @@ -161,7 +188,9 @@ describe("replaceFetchedAgentDirectory", () => { const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const projection = new AgentStoreProjection(serverId); + const projection = new AgentStoreProjection(serverId, (removedAgentId) => + useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), + ); projection.replaceFetched([ createEntry( createAgentPayload({ @@ -202,10 +231,15 @@ describe("replaceFetchedAgentDirectory", () => { serverId, new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), ); - store.setAgentTimelineCursor( - serverId, - new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]), - ); + store.applyAgentTimelineResponseState(serverId, agentId, { + items: [], + head: [], + range: { epoch: "epoch", startSeq: 1, endSeq: 2 }, + older: "none", + newer: false, + synchronized: false, + submissions: [], + }); store.setPendingPermissions( serverId, new Map([["permission", { key: "permission", agentId, request: null as never }]]), @@ -213,7 +247,9 @@ describe("replaceFetchedAgentDirectory", () => { store.setInitializingAgents(serverId, new Map([[agentId, true]])); setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - new AgentStoreProjection(serverId).applyDelta({ kind: "remove", agentId }); + new AgentStoreProjection(serverId, (removedAgentId) => + useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), + ).applyDelta({ kind: "remove", agentId }); const session = useSessionStore.getState().sessions[serverId]; expect({ @@ -273,7 +309,9 @@ describe("replaceFetchedAgentDirectory", () => { lastUsage: { inputTokens: 10, outputTokens: 5 }, pendingPermissions: [permission("current-permission")], }); - const projection = new AgentStoreProjection(serverId); + const projection = new AgentStoreProjection(serverId, (removedAgentId) => + useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId), + ); 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 d700a0f510..aff01550bf 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -10,6 +10,9 @@ 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;