mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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.
This commit is contained in:
+8
-15
@@ -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
|
||||
|
||||
+11
-7
@@ -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
|
||||
|
||||
+12
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+72
-38
@@ -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`
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -94,14 +94,18 @@ interface PopoverFrameRecorderWindow extends Window {
|
||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||
}
|
||||
|
||||
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
|
||||
return page.evaluate(
|
||||
async function expectAutocompleteCoveredAtPoint(page: Page, x: number, y: number): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
([pointX, pointY]) => {
|
||||
const element = document.elementFromPoint(pointX, pointY);
|
||||
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
),
|
||||
)
|
||||
.not.toBe("composer-autocomplete-popover");
|
||||
}
|
||||
|
||||
async function installListCommandsStub(page: Page): Promise<void> {
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -494,18 +494,19 @@ export async function holdAgentOlderTimelinePages(
|
||||
};
|
||||
}
|
||||
|
||||
export async function delayAgentBootstrapTailResponse(
|
||||
export async function delayAgentBootstrapTimelineResponse(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<BootstrapTimelineGate> {
|
||||
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<void>((resolve) => {
|
||||
resolveDelayedTail = resolve;
|
||||
const delayedBootstrap = new Promise<void>((resolve) => {
|
||||
resolveDelayedBootstrap = resolve;
|
||||
});
|
||||
const delayedCatchUp = new Promise<void>((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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<BootstrapTimelineGate> {
|
||||
return delayAgentBootstrapTailResponse(page, agent.agentId);
|
||||
return delayAgentBootstrapTimelineResponse(page, agent.agentId);
|
||||
}
|
||||
|
||||
export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise<string> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = <AppearanceStyleBoundary>{sidebarChrome}</AppearanceStyleBoundary>;
|
||||
}
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||
{sidebarChrome}
|
||||
{themedSidebarChrome}
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
{usesCompactExplorerHost ? (
|
||||
@@ -575,9 +579,12 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
</View>
|
||||
);
|
||||
|
||||
// Native panel gesture hosts outlive appearance keys, like native navigators.
|
||||
// Their tracked styles update in place; web numeric styles still need remounting.
|
||||
const surface = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
<AppearanceStyleBoundary>
|
||||
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
|
||||
<WindowChromeRegion corners="top-left">
|
||||
<WindowChromeSafeArea
|
||||
@@ -592,7 +599,9 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
) : null}
|
||||
<DesktopWindowControls />
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout ? sidebarChrome : null}
|
||||
</AppearanceStyleBoundary>
|
||||
{isCompactLayout ? themedSidebarChrome : null}
|
||||
<AppearanceStyleBoundary>
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
@@ -611,6 +620,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<KeyboardShortcutsDialog />
|
||||
<AppDiagnosticHost />
|
||||
<QuittingOverlay />
|
||||
</AppearanceStyleBoundary>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -672,7 +682,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
||||
{children}
|
||||
</VoiceProvider>
|
||||
</AppearanceProvider>
|
||||
);
|
||||
@@ -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 (
|
||||
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
|
||||
<ThemedStack
|
||||
screenOptions={ROOT_STACK_SCREEN_OPTIONS}
|
||||
nestedNavigatorScreens={ROOT_STACK_NESTED_NAVIGATOR_SCREENS}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <Fragment key={appearanceKey}>{children}</Fragment>;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</DiffDocumentWorkspaceCacheProvider>
|
||||
) : null;
|
||||
|
||||
let themedExplorer = explorer;
|
||||
if (isWeb) {
|
||||
themedExplorer = <AppearanceStyleBoundary>{explorer}</AppearanceStyleBoundary>;
|
||||
}
|
||||
|
||||
if (presentation === "dock") {
|
||||
return (
|
||||
<View style={styles.row} onLayout={handleContainerLayout}>
|
||||
<View style={styles.fill}>{children}</View>
|
||||
{explorer}
|
||||
{themedExplorer}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -194,7 +200,7 @@ export function CompactExplorerSidebarHost({
|
||||
>
|
||||
{children}
|
||||
</CompactExplorerOpenGestureSurface>
|
||||
{explorer}
|
||||
{themedExplorer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,8 +214,9 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
|
||||
if (createResult.agentId) {
|
||||
updatePendingAgentId({ draftId, agentId: createResult.agentId });
|
||||
handoffCreatedAgentMessageSubmission(
|
||||
pendingServerId,
|
||||
getHostRuntimeStore()
|
||||
.getTimelineReplica(pendingServerId)
|
||||
.handoffSubmission(
|
||||
createResult.agentId,
|
||||
createUserMessage({
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
|
||||
@@ -1484,7 +1484,10 @@ function ComposerContentImpl({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
}),
|
||||
encodeImages,
|
||||
submission: createMessageSubmissionWriter(serverId),
|
||||
submission: createMessageSubmissionWriter(
|
||||
serverId,
|
||||
getHostRuntimeStore().getTimelineReplica(serverId),
|
||||
),
|
||||
activeTurnBehavior,
|
||||
activeTurnId:
|
||||
activeTurnBehavior === "steer"
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
||||
const appStateRef = useRef(AppState.currentState);
|
||||
const forcedTimelineTailReplacements = useRef(new Set<string>());
|
||||
const viewedTimelineSyncRef = useRef<ViewedTimelineOwner | null>(null);
|
||||
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
|
||||
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>();
|
||||
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: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
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<void>((resolve) => {
|
||||
finishResponse = resolve;
|
||||
});
|
||||
let markStarted = () => {};
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
let oldFetch: Promise<GetProvidersSnapshotResponseMessage["payload"]> | 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, []);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<NativeStackNavigationOptions>(
|
||||
() => ({
|
||||
...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 <AppearanceStyleBoundary>{content}</AppearanceStyleBoundary>;
|
||||
},
|
||||
[nestedNavigatorScreens],
|
||||
);
|
||||
|
||||
return <Stack screenOptions={themedScreenOptions}>{children}</Stack>;
|
||||
return (
|
||||
<Stack screenOptions={themedScreenOptions} screenLayout={screenLayout}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const ThemedStack = withUnistyles(ThemedStackBase, (theme) => ({
|
||||
|
||||
@@ -7,23 +7,36 @@ import {
|
||||
|
||||
function createNavigationRef(rootState: unknown, options: { ready?: boolean } = {}) {
|
||||
const dispatch = vi.fn();
|
||||
const navigationRef = {
|
||||
current: {
|
||||
isReady: () => options.ready ?? true,
|
||||
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<ReactNavigation.RootParamList>;
|
||||
|
||||
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<ReactNavigation.RootParamList>)();
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ReactNavigation.RootParamList> | null =
|
||||
null;
|
||||
let pendingIntent: { route: string; deps: NavigateToHostWorkspaceRouteDeps } | null = null;
|
||||
|
||||
export function registerWorkspaceRouteNavigationRef(
|
||||
ref: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>,
|
||||
): () => 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<ReactNavigation.RootParamList>,
|
||||
): 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, number>();
|
||||
private readonly members = new Set<string>();
|
||||
private readonly pendingCacheReads = new Set<string>();
|
||||
private readonly storeProjection: AgentStoreProjection;
|
||||
|
||||
constructor(
|
||||
private readonly serverId: string,
|
||||
private readonly onStoppedRunning: (agentId: string) => void,
|
||||
private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void,
|
||||
removeTimeline: (agentId: string, reason: AgentRemovalReason) => void,
|
||||
private readonly acceptTimeline: (agentId: string) => void = () => undefined,
|
||||
) {
|
||||
this.storeProjection = new AgentStoreProjection(serverId);
|
||||
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<string, Agent> {
|
||||
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<string, Agent>): 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<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<DaemonClient["fetchWorkspaces"]>>;
|
||||
type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
|
||||
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
|
||||
type TimelineFetchResult = Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>;
|
||||
|
||||
class FakeDirectoryClient {
|
||||
fetchAgentsCalls = 0;
|
||||
@@ -29,6 +38,10 @@ class FakeDirectoryClient {
|
||||
projectResult: ProjectListResult | null = null;
|
||||
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
|
||||
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||
private readonly pendingTimelineFetches: Array<{
|
||||
promise: Promise<TimelineFetchResult>;
|
||||
resolve(result: TimelineFetchResult): void;
|
||||
}> = [];
|
||||
private readonly handlers = new Map<
|
||||
SessionOutboundMessage["type"],
|
||||
Set<(message: SessionOutboundMessage) => void>
|
||||
@@ -98,6 +111,21 @@ class FakeDirectoryClient {
|
||||
};
|
||||
}
|
||||
|
||||
fetchAgentTimeline(): Promise<TimelineFetchResult> {
|
||||
let resolve!: (result: TimelineFetchResult) => void;
|
||||
const promise = new Promise<TimelineFetchResult>((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<ProjectListResult> {
|
||||
this.listProjectsCalls += 1;
|
||||
this.lastProjectOptions = options;
|
||||
@@ -121,20 +149,102 @@ class FakeDirectoryClient {
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryRowStore implements ReplicaRowStore {
|
||||
readonly rows = new Map<string, ReplicaRow>();
|
||||
readAllGate: Promise<void> | null = null;
|
||||
|
||||
private key(row: Pick<ReplicaRow, "serverId" | "kind" | "id">): string {
|
||||
return `${row.serverId}:${row.kind}:${row.id}`;
|
||||
}
|
||||
|
||||
async open(): Promise<void> {}
|
||||
|
||||
async read(
|
||||
serverIds: readonly string[],
|
||||
kinds: readonly ReplicaRow["kind"][],
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
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<ReplicaHostRows[]> {
|
||||
await this.readAllGate;
|
||||
const hosts = new Map<string, ReplicaRow[]>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key);
|
||||
}
|
||||
|
||||
async renameHost(oldServerId: string, newServerId: string): Promise<void> {
|
||||
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<void> {
|
||||
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<ReplicaCache["replaceDirectoryBaseline"]>[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<string>();
|
||||
|
||||
function createDirectory(serverId: string): {
|
||||
function createDirectory(
|
||||
serverId: string,
|
||||
callbacks: ConstructorParameters<typeof DirectorySync>[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,
|
||||
const cachedAgent = createAgent(serverId, "agent-1");
|
||||
cachedAgent.workspaceId = cachedWorkspace.id;
|
||||
const directory = new DirectorySync(serverId, noopCallbacks, {
|
||||
readDirectory: async () => ({
|
||||
agents: new Map(),
|
||||
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<typeof cachedAgent>((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(),
|
||||
const cachedAgent = createAgent(serverId, "agent-1");
|
||||
const { cache, releaseLoad } = await savedDirectory(serverId, {
|
||||
agents: new Map([[cachedAgent.id, cachedAgent]]),
|
||||
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<typeof cachedAgent>((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(),
|
||||
const { cache, releaseLoad } = await savedDirectory(serverId, {
|
||||
agents: new Map([[cachedAgent.id, cachedAgent]]),
|
||||
workspaces: new Map(),
|
||||
projects: new Map(),
|
||||
}),
|
||||
commitDirectoryMutations: () => undefined,
|
||||
},
|
||||
);
|
||||
directory.connectionChanged({
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online",
|
||||
source: { clientGeneration: 1, connectionEpoch: 1 },
|
||||
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(),
|
||||
|
||||
@@ -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<ReturnType<DaemonClient["fetchAgentTimeline"]>>;
|
||||
|
||||
const PAGE_LIMIT = 200;
|
||||
const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
|
||||
{ key: "updated_at", direction: "desc" },
|
||||
@@ -108,8 +108,6 @@ export interface DirectoryConnection {
|
||||
}
|
||||
|
||||
export interface DirectoryCheckpointStorage {
|
||||
readAgent(serverId: string, agentId: string): Promise<Agent | undefined>;
|
||||
readWorkspace(serverId: string, workspaceId: string): Promise<CachedWorkspace | undefined>;
|
||||
readDirectory(serverId: string): Promise<CachedDirectory>;
|
||||
commitDirectoryMutations(
|
||||
serverId: string,
|
||||
@@ -139,6 +137,7 @@ export class DirectorySync {
|
||||
WorkspaceDirectorySnapshot,
|
||||
WorkspaceDirectoryDelta
|
||||
>();
|
||||
private readonly timelineRequests = new Map<string, Promise<TimelinePage>>();
|
||||
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<void> | 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<void>;
|
||||
private readonly routeDemandIds = new Set<string>();
|
||||
private readonly fullDemandSources = new Set<object>();
|
||||
private demandRefresh: Promise<void> | 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<void> {
|
||||
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;
|
||||
if (message.type !== "agent_deleted" || !this.isCurrent(client, source)) return;
|
||||
this.agents.remove(message.payload.agentId);
|
||||
this.persistCheckpoint();
|
||||
}
|
||||
}),
|
||||
client.on("agent_archived", (message) => {
|
||||
if (message.type === "agent_archived" && this.isCurrent(client, source)) {
|
||||
this.revision += 1;
|
||||
if (message.type !== "agent_archived" || !this.isCurrent(client, source)) return;
|
||||
this.agents.archive(message.payload.agentId, message.payload.archivedAt);
|
||||
this.persistCheckpoint();
|
||||
}
|
||||
}),
|
||||
];
|
||||
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<void> {
|
||||
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<void> {
|
||||
private async restoreBaseline(): Promise<void> {
|
||||
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<void> {
|
||||
await this.loadCachedAgent(agentId);
|
||||
const agent = useSessionStore.getState().sessions[this.serverId]?.agents.get(agentId);
|
||||
if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId);
|
||||
}
|
||||
|
||||
async prepareWorkspaceRoute(workspaceId: string): Promise<void> {
|
||||
await this.loadCachedWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
private async loadCachedWorkspace(workspaceId: string): Promise<void> {
|
||||
if (!this.checkpoints) return;
|
||||
if (useSessionStore.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
|
||||
const revision = this.workspaceRevision;
|
||||
const cached = await this.checkpoints.readWorkspace(this.serverId, workspaceId);
|
||||
if (!cached) return;
|
||||
if (this.workspaceRevision !== revision) return;
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
if (!session) return;
|
||||
this.workspaces.commitCachedWorkspace(cached.workspace, cached.project);
|
||||
}
|
||||
|
||||
private loadCachedDirectory(): Promise<void> {
|
||||
const checkpoints = this.checkpoints;
|
||||
if (!checkpoints) return Promise.resolve();
|
||||
this.cacheLoad ??= (async () => {
|
||||
const revision = this.revision;
|
||||
const cached = await checkpoints.readDirectory(this.serverId);
|
||||
if (this.cacheAccepted || this.revision !== revision) return;
|
||||
const cached = await this.checkpoints.readDirectory(this.serverId);
|
||||
if (!useSessionStore.getState().sessions[this.serverId]) return;
|
||||
this.agents.commitCached(cached.agents);
|
||||
this.workspaces.commitCached(cached);
|
||||
this.cursors = cached.checkpoint ?? {};
|
||||
this.cacheAccepted = true;
|
||||
})();
|
||||
return this.cacheLoad;
|
||||
}
|
||||
|
||||
restoreCachedDirectory(): Promise<void> {
|
||||
return this.loadCachedDirectory();
|
||||
}
|
||||
|
||||
applyAgentTurnLiveness(
|
||||
@@ -412,33 +357,36 @@ export class DirectorySync {
|
||||
async fetchTimeline(
|
||||
agentId: string,
|
||||
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
|
||||
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
|
||||
const { client } = this.requireOnline();
|
||||
replaceTail = false,
|
||||
): Promise<TimelinePage> {
|
||||
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();
|
||||
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<RefreshAgentDirectoryResult> {
|
||||
return this.refreshAgentsInternal(input, true);
|
||||
}
|
||||
|
||||
private async refreshAgentsInternal(
|
||||
input: RefreshAgentDirectoryInput,
|
||||
loadDirectoryCache: boolean,
|
||||
): Promise<RefreshAgentDirectoryResult> {
|
||||
if (loadDirectoryCache) {
|
||||
this.primeAgentTransactionForCacheLoad();
|
||||
await this.loadCachedDirectory();
|
||||
}
|
||||
const onlineConnection = this.getOnlineConnection();
|
||||
if (!onlineConnection) {
|
||||
await this.baseline;
|
||||
this.callbacks.markAgentReady();
|
||||
return {
|
||||
agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents),
|
||||
@@ -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<void> {
|
||||
return this.refreshWorkspacesInternal(input, true);
|
||||
}
|
||||
|
||||
private async refreshWorkspacesInternal(
|
||||
input: { subscribe?: boolean } | undefined,
|
||||
loadDirectoryCache: boolean,
|
||||
): Promise<void> {
|
||||
if (loadDirectoryCache) await this.loadCachedDirectory();
|
||||
const onlineConnection = this.getOnlineConnection();
|
||||
if (!onlineConnection) 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;
|
||||
}
|
||||
|
||||
@@ -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<string, Agent> {
|
||||
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 = <T>(current: Map<string, T>): Map<string, T> => {
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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<DaemonClient["fetchAgents"]>> | ReturnType<DaemonClient["fetchAgents"]>
|
||||
> = [];
|
||||
public timelineCalls = 0;
|
||||
public timelineResponse: ReturnType<DaemonClient["fetchAgentTimeline"]> | null = null;
|
||||
fetchAgentTimeline(): ReturnType<DaemonClient["fetchAgentTimeline"]> {
|
||||
this.timelineCalls++;
|
||||
if (!this.timelineResponse) throw new Error("Missing timeline response");
|
||||
return this.timelineResponse;
|
||||
}
|
||||
public sentAgentMessages: Array<Parameters<DaemonClient["sendAgentMessage"]>> = [];
|
||||
public sendAgentMessageFailures: Error[] = [];
|
||||
public sendAgentMessageResponses: Promise<void>[] = [];
|
||||
@@ -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<string, ReplicaRow[]>();
|
||||
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<ReturnType<DaemonClient["fetchAgentTimeline"]>> = {
|
||||
requestId: "page",
|
||||
agentId: "agent",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
epoch: "epoch",
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { epoch: "epoch", seq: 1 },
|
||||
endCursor: { epoch: "epoch", seq: 1 },
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
error: null,
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "first", messageId: "first" },
|
||||
timestamp: "2026-09-06T00:00:00Z",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const viewed = store.getViewedTimelineOwner(host.serverId);
|
||||
if (scenario !== "coalesced") viewed.applyTimelineResponse(page);
|
||||
const response = createDeferred<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>>();
|
||||
client.timelineResponse = response.promise;
|
||||
let publications = 0;
|
||||
const unsubscribe = useSessionStore.subscribe((state, previous) => {
|
||||
if (
|
||||
state.sessions[host.serverId]?.agentStreamTail !==
|
||||
previous.sessions[host.serverId]?.agentStreamTail
|
||||
)
|
||||
publications++;
|
||||
});
|
||||
const appendLive = () => {
|
||||
viewed.enqueueStreamEvent("agent", {
|
||||
epoch: "epoch",
|
||||
seq: 2,
|
||||
timestamp: new Date("2026-09-06T00:00:01Z"),
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "new live", messageId: "new-live" },
|
||||
},
|
||||
});
|
||||
viewed.flushStreamAgent("agent");
|
||||
};
|
||||
if (scenario === "rewind") appendLive();
|
||||
const request = planTimelineTailFetch();
|
||||
const first = store.fetchAgentTimeline(
|
||||
host.serverId,
|
||||
"agent",
|
||||
request,
|
||||
scenario === "late-tail",
|
||||
);
|
||||
const second =
|
||||
scenario === "coalesced"
|
||||
? store.fetchAgentTimeline(host.serverId, "agent", request)
|
||||
: first;
|
||||
if (scenario === "late-tail" || scenario === "daemon-reset") appendLive();
|
||||
if (scenario === "daemon-reset") page.reset = true;
|
||||
response.resolve(page);
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([page, page]);
|
||||
expect(client.timelineCalls).toBe(1);
|
||||
if (scenario === "coalesced") expect(publications).toBe(1);
|
||||
else {
|
||||
const session = useSessionStore.getState().sessions[host.serverId];
|
||||
expect(
|
||||
[
|
||||
...(session?.agentStreamTail.get("agent") ?? []),
|
||||
...(session?.agentStreamHead.get("agent") ?? []),
|
||||
].map((item) => item.id),
|
||||
).toEqual(scenario === "late-tail" ? ["first", "new-live"] : ["first"]);
|
||||
expect(store.getTimelineReplica(host.serverId).readCursor("agent")?.endSeq).toBe(
|
||||
scenario === "late-tail" ? 2 : 1,
|
||||
);
|
||||
}
|
||||
unsubscribe();
|
||||
store.syncHosts([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["settled", "captured", "renamed"] as const)(
|
||||
"retains scoped-out history after host identity reconciliation (%s) and deletes it only explicitly",
|
||||
async (ordering) => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_temporary",
|
||||
connections: [makeHost().connections[0]!],
|
||||
});
|
||||
const resolvedId = "srv_resolved";
|
||||
const rows = createMemoryReplicaRowStore();
|
||||
const seed = new ReplicaCache(rows, { clearLegacyCache: async () => undefined });
|
||||
seed.setHosts([host.serverId]);
|
||||
const saved = {
|
||||
agentId: "agent",
|
||||
hasOlder: false,
|
||||
range: { epoch: "epoch", startSeq: 1, endSeq: 1 },
|
||||
items: [
|
||||
{
|
||||
kind: "assistant_message" as const,
|
||||
id: "saved",
|
||||
text: "saved history",
|
||||
timestamp: new Date("2026-09-06T00:00:00Z"),
|
||||
timelineCursor: { epoch: "epoch", seq: 1 },
|
||||
source: { startSeq: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
seed.commitTimeline(host.serverId, "agent", saved);
|
||||
await seed.flush();
|
||||
const client = new FakeDaemonClient();
|
||||
client.setConnectionState({ status: "connected" });
|
||||
const store = new HostRuntimeStore({
|
||||
replicaRowStore: rows,
|
||||
deps: {
|
||||
createClient: () => client as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host: current }) => ({
|
||||
client: client as unknown as DaemonClient,
|
||||
serverId: current.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "test",
|
||||
},
|
||||
});
|
||||
const storedRows = async () =>
|
||||
(await rows.readAll()).find((entry) => entry.serverId === resolvedId)?.rows ?? [];
|
||||
const hasStoredKind = async (kind: ReplicaRow["kind"]) =>
|
||||
(await storedRows()).some((row) => row.kind === kind);
|
||||
try {
|
||||
store.syncHosts([host]);
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
const replica = store.getTimelineReplica(host.serverId);
|
||||
const started = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
const readRows = rows.read.bind(rows);
|
||||
let reads = 0;
|
||||
rows.read = async (...args) => {
|
||||
if (!args[1].includes("timeline")) return readRows(...args);
|
||||
reads++;
|
||||
if (ordering === "settled" || reads !== 1) return readRows(...args);
|
||||
const captured = ordering === "captured" ? await readRows(...args) : undefined;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return captured ?? readRows(...args);
|
||||
};
|
||||
const preparation = replica.prepare("agent");
|
||||
if (ordering === "settled") {
|
||||
await preparation;
|
||||
expect(
|
||||
useSessionStore.getState().sessions[host.serverId]?.agentStreamTail.get("agent"),
|
||||
).toEqual(saved.items);
|
||||
} else await started.promise;
|
||||
store.reconcileServerId(host.serverId, resolvedId);
|
||||
if (ordering !== "settled") {
|
||||
await vi.waitFor(async () => expect(await hasStoredKind("timeline")).toBe(true));
|
||||
release.resolve();
|
||||
await preparation;
|
||||
}
|
||||
expect(replica.prepare("agent")).toBe(preparation);
|
||||
expect(reads).toBe(ordering === "settled" ? 1 : 2);
|
||||
expect(store.getTimelineReplica(resolvedId)).toBe(replica);
|
||||
expect(
|
||||
useSessionStore.getState().sessions[resolvedId]?.agentStreamTail.get("agent"),
|
||||
).toEqual(saved.items);
|
||||
expect(replica.readCursor("agent")).toEqual({ epoch: "epoch", endSeq: 1 });
|
||||
await waitForHostOnline(store, resolvedId);
|
||||
useSessionStore.getState().updateSessionServerInfo(resolvedId, {
|
||||
serverId: resolvedId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true },
|
||||
});
|
||||
store.acceptAgentSnapshot(
|
||||
resolvedId,
|
||||
normalizeAgentSnapshot(
|
||||
makeFetchAgentsEntry({ id: "agent", cwd: "/repo", updatedAt: "2026-09-06T00:00:00Z" })
|
||||
.agent,
|
||||
resolvedId,
|
||||
),
|
||||
);
|
||||
await vi.waitFor(async () => expect(await hasStoredKind("agent")).toBe(true), {
|
||||
timeout: 3000,
|
||||
});
|
||||
await store.refreshAgentDirectory({ serverId: resolvedId });
|
||||
store.setAppVisible(false);
|
||||
await vi.waitFor(async () => expect(await hasStoredKind("agent")).toBe(false));
|
||||
const reopened = new ReplicaCache(rows, { clearLegacyCache: async () => undefined });
|
||||
reopened.setHosts([resolvedId]);
|
||||
expect(await reopened.readTimeline(resolvedId, "agent")).toEqual(saved);
|
||||
store.restoreAgentSnapshot(resolvedId, "agent", undefined);
|
||||
store.setAppVisible(false);
|
||||
await vi.waitFor(async () => expect(await hasStoredKind("timeline")).toBe(false));
|
||||
} finally {
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
useSessionStore.getState().clearSession(resolvedId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("replaces stale active session state when active bootstrap omits an agent", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_archived_rehydrate",
|
||||
|
||||
@@ -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<string, DirectorySync>();
|
||||
private nextCancellationRequestId = 0;
|
||||
private timelineReplicaByServer = new Map<string, TimelineReplica>();
|
||||
private viewedTimelineByServer = new Map<string, ViewedTimelineOwner>();
|
||||
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
||||
private bootPromise: Promise<void> | null = null;
|
||||
private appVisible = true;
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
await directory.prepareWorkspaceRoute(workspaceId);
|
||||
}
|
||||
|
||||
async prepareAgentTimeline(serverId: string, agentId: string): Promise<void> {
|
||||
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<DaemonClient["fetchAgentTimeline"]>[1],
|
||||
replaceTail = false,
|
||||
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
return directory.fetchTimeline(agentId, request);
|
||||
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 {
|
||||
|
||||
@@ -25,41 +25,58 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
readonly rows = new Map<string, ReplicaRow>();
|
||||
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<void> | null = null;
|
||||
onRead: (() => void) | null = null;
|
||||
readAllGate: Promise<void> | null = null;
|
||||
writeGate: Promise<void> | null = null;
|
||||
renameGate: Promise<void> | null = null;
|
||||
|
||||
private key(row: Pick<ReplicaRow, "serverId" | "kind" | "id">): string {
|
||||
return `${row.serverId}:${row.kind}:${row.id}`;
|
||||
}
|
||||
|
||||
async open(): Promise<void> {}
|
||||
async open(): Promise<void> {
|
||||
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<ReplicaRow[]> {
|
||||
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<ReplicaHostRows[]> {
|
||||
this.readAlls += 1;
|
||||
await this.readAllGate;
|
||||
if (this.nextReadAllFailure) {
|
||||
const error = this.nextReadAllFailure;
|
||||
this.nextReadAllFailure = null;
|
||||
throw error;
|
||||
}
|
||||
const hosts = new Map<string, ReplicaRow[]>();
|
||||
for (const row of this.rows.values()) {
|
||||
const rows = hosts.get(row.serverId) ?? [];
|
||||
@@ -71,6 +88,7 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
|
||||
async apply(changes: ReplicaRowChanges): Promise<void> {
|
||||
this.writes += 1;
|
||||
await this.writeGate;
|
||||
if (this.nextWriteFailure) {
|
||||
const error = this.nextWriteFailure;
|
||||
this.nextWriteFailure = null;
|
||||
@@ -82,10 +100,16 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
}
|
||||
|
||||
async deleteHost(serverId: string): Promise<void> {
|
||||
if (this.nextDeleteFailure) {
|
||||
const error = this.nextDeleteFailure;
|
||||
this.nextDeleteFailure = null;
|
||||
throw error;
|
||||
}
|
||||
for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key);
|
||||
}
|
||||
|
||||
async renameHost(oldServerId: string, newServerId: string): Promise<void> {
|
||||
await this.renameGate;
|
||||
for (const [key, row] of this.rows) {
|
||||
if (row.serverId !== oldServerId) continue;
|
||||
this.rows.delete(key);
|
||||
@@ -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<ReturnType<ReplicaCache["readTimeline"]>>;
|
||||
const reading = cache.readTimeline(SERVER_ID, "agent-1").then((value) => {
|
||||
restored = value;
|
||||
return value;
|
||||
});
|
||||
try {
|
||||
await expect
|
||||
.poll(() => restored?.items, { timeout: 200 })
|
||||
.toEqual([timelineItem("Saved before restart")]);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await Promise.all([writing, reading]);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads rows under a resolved host identity before the disk rename finishes", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Saved under temporary host ID"));
|
||||
await writer.flush();
|
||||
const reader = createCache(storage);
|
||||
const release = deferred();
|
||||
storage.renameGate = release.promise;
|
||||
reader.reconcileServerId(SERVER_ID, "resolved-host");
|
||||
expect((await reader.readTimeline("resolved-host", "agent-1"))?.items).toEqual([
|
||||
timelineItem("Saved under temporary host ID"),
|
||||
]);
|
||||
release.resolve();
|
||||
await reader.flush();
|
||||
expect(storage.rows.has("resolved-host:timeline:agent-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps rename collision precedence and accepted baselines during lazy reads", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
writer.setHosts([SERVER_ID, "resolved-host"]);
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Moved"));
|
||||
writer.commitTimeline("resolved-host", "agent-1", timeline("Superseded target"));
|
||||
commitDirectory(writer, SERVER_ID, directory());
|
||||
await writer.flush();
|
||||
const reader = createCache(storage);
|
||||
reader.setHosts([SERVER_ID, "resolved-host"]);
|
||||
const release = deferred();
|
||||
storage.renameGate = release.promise;
|
||||
reader.replaceDirectoryBaseline(SERVER_ID, {
|
||||
...directory(),
|
||||
workspaces: new Map(),
|
||||
projects: new Map(),
|
||||
});
|
||||
reader.reconcileServerId(SERVER_ID, "resolved-host");
|
||||
try {
|
||||
expect((await reader.readTimeline("resolved-host", "agent-1"))?.items).toEqual([
|
||||
timelineItem("Moved"),
|
||||
]);
|
||||
const accepted = await reader.readDirectory("resolved-host");
|
||||
expect(accepted.agents.get("agent-1")?.serverId).toBe("resolved-host");
|
||||
expect(accepted.workspaces.size).toBe(0);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await reader.flush();
|
||||
}
|
||||
const reopened = new ReplicaCache(storage, noLegacyCleanup);
|
||||
reopened.setHosts(["resolved-host"]);
|
||||
expect((await reopened.readDirectory("resolved-host")).workspaces.size).toBe(0);
|
||||
expect((await reopened.readTimeline("resolved-host", "agent-1"))?.items).toEqual([
|
||||
timelineItem("Moved"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a committed timeline before it is written", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Accepted"));
|
||||
|
||||
expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([
|
||||
timelineItem("Accepted"),
|
||||
]);
|
||||
expect(storage.writes).toBe(0);
|
||||
});
|
||||
|
||||
it("round-trips a row's source start and chunk provenance", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", {
|
||||
...timeline(),
|
||||
items: [
|
||||
{
|
||||
...timelineItem("Streamed"),
|
||||
source: {
|
||||
startSeq: 10,
|
||||
chunks: [
|
||||
{ seq: 10, offset: 0 },
|
||||
{ seq: 12, offset: 4 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await cache.flush();
|
||||
|
||||
const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
|
||||
expect(restored?.items[0]).toMatchObject({ source: { startSeq: 10 } });
|
||||
expect(restored?.items[0]?.source?.chunks).toEqual([
|
||||
{ seq: 10, offset: 0 },
|
||||
{ seq: 12, offset: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists the owner's forty-entry restart page from a long synced history", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
const items = Array.from({ length: 40 }, (_, index) => ({
|
||||
...timelineItem(`Message ${index}`),
|
||||
id: `message-${index}`,
|
||||
timelineCursor: { epoch: "epoch-1", seq: index + 201 },
|
||||
source: { startSeq: index + 201, chunks: [{ seq: index + 201, offset: 0 }] },
|
||||
}));
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", {
|
||||
agentId: "agent-1",
|
||||
items,
|
||||
range: { epoch: "epoch-1", startSeq: 201, endSeq: 240 },
|
||||
hasOlder: true,
|
||||
});
|
||||
await cache.flush();
|
||||
|
||||
const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
|
||||
expect(restored?.items).toHaveLength(40);
|
||||
expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 201, endSeq: 240 });
|
||||
expect(restored?.hasOlder).toBe(true);
|
||||
});
|
||||
|
||||
it("encodes the owner's certified page without a per-timeline byte ceiling", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
const items = Array.from({ length: 40 }, (_, index) => ({
|
||||
...timelineItem("x".repeat(20_000)),
|
||||
id: `message-${index}`,
|
||||
timelineCursor: { epoch: "epoch-1", seq: index + 1 },
|
||||
source: { startSeq: index + 1, chunks: [{ seq: index + 1, offset: 0 }] },
|
||||
}));
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", {
|
||||
agentId: "agent-1",
|
||||
items,
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 40 },
|
||||
hasOlder: true,
|
||||
});
|
||||
await cache.flush();
|
||||
|
||||
const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
|
||||
expect(restored?.items.map((item) => item.id)).toEqual(items.map((item) => item.id));
|
||||
expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 40 });
|
||||
expect(restored?.hasOlder).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing until an owner explicitly commits data", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
@@ -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();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -108,20 +108,17 @@ export function createSqliteReplicaRowStore(
|
||||
}
|
||||
|
||||
async function read(
|
||||
serverId: string,
|
||||
serverIds: readonly string[],
|
||||
kinds: readonly ReplicaRow["kind"][],
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
if (kinds.length === 0 || ids?.length === 0) return [];
|
||||
const placeholders = kinds.map(() => "?").join(", ");
|
||||
const idClause = ids ? ` AND id IN (${ids.map(() => "?").join(", ")})` : "";
|
||||
const storedRows = await getConnection().all<StoredRow>(
|
||||
`SELECT server_id, kind, id, payload FROM rows
|
||||
WHERE server_id = ? AND kind IN (${placeholders})${idClause}
|
||||
ORDER BY kind, id`,
|
||||
[serverId, ...kinds, ...(ids ?? [])],
|
||||
if (!serverIds.length || !kinds.length || ids?.length === 0) return [];
|
||||
const placeholders = (values: readonly string[]) => values.map(() => "?").join(",");
|
||||
const rows = await getConnection().all<StoredRow>(
|
||||
`SELECT server_id, kind, id, payload FROM rows WHERE server_id IN (${placeholders(serverIds)}) AND kind IN (${placeholders(kinds)})${ids ? ` AND id IN (${placeholders(ids)})` : ""} ORDER BY server_id, kind, id`,
|
||||
[...serverIds, ...kinds, ...(ids ?? [])],
|
||||
);
|
||||
return storedRows.map((row) => ({
|
||||
return rows.map((row) => ({
|
||||
serverId: row.server_id,
|
||||
kind: row.kind,
|
||||
id: row.id,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface ReplicaHostRows {
|
||||
export interface ReplicaRowStore {
|
||||
open(): Promise<void>;
|
||||
read(
|
||||
serverId: string,
|
||||
serverIds: readonly string[],
|
||||
kinds: readonly ReplicaRowKind[],
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]>;
|
||||
|
||||
@@ -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" },
|
||||
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(get).toHaveBeenCalledWith(["server-a", "agent", "agent-1"]);
|
||||
expect(getAll).not.toHaveBeenCalled();
|
||||
|
||||
expect(openCursor).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
get.mockRestore();
|
||||
getAll.mockRestore();
|
||||
openCursor.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<ReplicaRow[]> {
|
||||
if (kinds.length === 0 || ids?.length === 0) return [];
|
||||
const transaction = getDatabase().transaction(ROWS_STORE, "readonly");
|
||||
const completion = transactionComplete(transaction);
|
||||
const store = transaction.objectStore(ROWS_STORE);
|
||||
const requests = ids
|
||||
? kinds.flatMap((kind) =>
|
||||
ids.map(async (id) => {
|
||||
const row = await requestResult<ReplicaRow | undefined>(
|
||||
store.get([serverId, kind, id]),
|
||||
);
|
||||
return row ? [row] : [];
|
||||
}),
|
||||
)
|
||||
: kinds.map((kind) =>
|
||||
requestResult<ReplicaRow[]>(
|
||||
store.getAll(IDBKeyRange.bound([serverId, kind], [serverId, kind, []])),
|
||||
),
|
||||
);
|
||||
const rows = (await Promise.all(requests)).flat();
|
||||
await completion;
|
||||
return rows.sort((left, right) =>
|
||||
left.kind === right.kind
|
||||
? left.id.localeCompare(right.id)
|
||||
: left.kind.localeCompare(right.kind),
|
||||
);
|
||||
}
|
||||
|
||||
async function apply(changes: ReplicaRowChanges): Promise<void> {
|
||||
await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => {
|
||||
const rows = transaction.objectStore(ROWS_STORE);
|
||||
@@ -180,6 +148,39 @@ export function createIndexedDbReplicaRowStore(
|
||||
});
|
||||
}
|
||||
|
||||
async function read(
|
||||
serverIds: readonly string[],
|
||||
kinds: readonly ReplicaRow["kind"][],
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
const transaction = getDatabase().transaction(ROWS_STORE, "readonly");
|
||||
const completion = transactionComplete(transaction);
|
||||
const store = transaction.objectStore(ROWS_STORE);
|
||||
const requests: Promise<ReplicaRow[]>[] = [];
|
||||
for (const serverId of serverIds) {
|
||||
for (const kind of kinds) {
|
||||
if (ids) {
|
||||
for (const id of ids) {
|
||||
requests.push(
|
||||
requestResult<ReplicaRow | undefined>(store.get([serverId, kind, id])).then((row) =>
|
||||
row ? [row] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
requests.push(
|
||||
requestResult<ReplicaRow[]>(
|
||||
store.getAll(IDBKeyRange.bound([serverId, kind], [serverId, kind, []])),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const rows = (await Promise.all(requests)).flat();
|
||||
await completion;
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function deleteHost(serverId: string): Promise<void> {
|
||||
await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => {
|
||||
const rows = transaction.objectStore(ROWS_STORE);
|
||||
|
||||
@@ -61,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<ViewedTimelineDemandDeclaration | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (!persistenceKey || !viewedTimelineSync) {
|
||||
return;
|
||||
}
|
||||
viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, visibleAgentIds);
|
||||
}, [persistenceKey, viewedTimelineSync, visibleAgentIds]);
|
||||
useEffect(() => {
|
||||
if (!persistenceKey || !viewedTimelineSync) {
|
||||
return;
|
||||
const declaration = viewedTimelineSync.registerVisibleAgentIds(persistenceKey, []);
|
||||
visibleTimelineDeclarationRef.current = declaration;
|
||||
return () => {
|
||||
if (visibleTimelineDeclarationRef.current === declaration) {
|
||||
visibleTimelineDeclarationRef.current = null;
|
||||
}
|
||||
return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []);
|
||||
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(() => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, StreamItem[]>
|
||||
| ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>),
|
||||
) => void;
|
||||
setAgentStreamHead: (
|
||||
serverId: string,
|
||||
state:
|
||||
| Map<string, StreamItem[]>
|
||||
| ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>),
|
||||
) => void;
|
||||
setAgentStreamState: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
state: {
|
||||
tail?: StreamItem[];
|
||||
head?: StreamItem[];
|
||||
acknowledgedClientMessageIds?: readonly string[];
|
||||
taskSnapshot?: TodoEntry[];
|
||||
},
|
||||
) => void;
|
||||
beginAgentMessageSubmission: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
message: UserMessageItem,
|
||||
) => void;
|
||||
acceptAgentMessageSubmission: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
clientMessageId: string,
|
||||
) => void;
|
||||
rejectAgentMessageSubmission: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
clientMessageId: string,
|
||||
) => MessageSubmissionRejectionOutcome;
|
||||
handoffCreatedAgentUserMessage: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
message: UserMessageItem,
|
||||
) => boolean;
|
||||
clearAgentStreamHead: (serverId: string, agentId: string) => void;
|
||||
setAgentTimelineCursor: (
|
||||
serverId: string,
|
||||
state:
|
||||
| Map<string, AgentTimelineCursorState>
|
||||
| ((prev: Map<string, AgentTimelineCursorState>) => Map<string, AgentTimelineCursorState>),
|
||||
) => void;
|
||||
setAgentTimelineHasOlder: (
|
||||
serverId: string,
|
||||
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
||||
) => void;
|
||||
setAgentTimelineHasNewer: (
|
||||
serverId: string,
|
||||
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
||||
) => void;
|
||||
|
||||
setAgentTimelineOlderFetchInFlight: (
|
||||
serverId: string,
|
||||
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
||||
) => void;
|
||||
bumpHistorySyncGeneration: (serverId: string) => void;
|
||||
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
|
||||
setAgentAuthoritativeHistoryApplied: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
applied: boolean,
|
||||
) => void;
|
||||
|
||||
applyAgentTimelineResponseState: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
@@ -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<SessionStore>()(
|
||||
},
|
||||
|
||||
// Stream state (head/tail model)
|
||||
setAgentStreamTail: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextState = typeof state === "function" ? state(session.agentStreamTail) : state;
|
||||
if (session.agentStreamTail === nextState) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentStreamTail: nextState },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentStreamHead: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextState = typeof state === "function" ? state(session.agentStreamHead) : state;
|
||||
if (session.agentStreamHead === nextState) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentStreamHead: nextState },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentStreamState: (serverId, agentId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
let nextTail = session.agentStreamTail;
|
||||
let nextHead = session.agentStreamHead;
|
||||
let changedTail = false;
|
||||
let changedHead = false;
|
||||
|
||||
if (state.tail !== undefined) {
|
||||
const existingTail = session.agentStreamTail.get(agentId);
|
||||
if (existingTail !== state.tail) {
|
||||
nextTail = new Map(session.agentStreamTail);
|
||||
nextTail.set(agentId, state.tail);
|
||||
changedTail = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.head !== undefined) {
|
||||
const existingHead = session.agentStreamHead.get(agentId);
|
||||
const shouldDeleteHead = state.head.length === 0;
|
||||
if (shouldDeleteHead) {
|
||||
if (session.agentStreamHead.has(agentId)) {
|
||||
nextHead = new Map(session.agentStreamHead);
|
||||
nextHead.delete(agentId);
|
||||
changedHead = true;
|
||||
}
|
||||
} else if (existingHead !== state.head) {
|
||||
nextHead = new Map(session.agentStreamHead);
|
||||
nextHead.set(agentId, state.head);
|
||||
changedHead = true;
|
||||
}
|
||||
}
|
||||
|
||||
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
||||
const observedSubmissions = observeMessageSubmissionCanonical(
|
||||
currentSubmissions,
|
||||
state.acknowledgedClientMessageIds ?? [],
|
||||
);
|
||||
const changedSubmissions = observedSubmissions !== currentSubmissions;
|
||||
const agentTasks = updateAgentTasks(session.agentTasks, agentId, state.taskSnapshot);
|
||||
const changedTasks = agentTasks !== session.agentTasks;
|
||||
|
||||
if (!changedTail && !changedHead && !changedSubmissions && !changedTasks) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
let messageSubmissions = session.messageSubmissions;
|
||||
if (changedSubmissions) {
|
||||
messageSubmissions = new Map(session.messageSubmissions);
|
||||
if (observedSubmissions.length > 0) {
|
||||
messageSubmissions.set(agentId, observedSubmissions);
|
||||
} else {
|
||||
messageSubmissions.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail: nextTail,
|
||||
agentStreamHead: nextHead,
|
||||
agentTasks,
|
||||
messageSubmissions,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
beginAgentMessageSubmission: (serverId, agentId, message) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
if (!message.clientMessageId) {
|
||||
throw new Error("Beginning a message submission requires client identity");
|
||||
}
|
||||
const currentTail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const currentHead = session.agentStreamHead.get(agentId) ?? [];
|
||||
const stream = appendSubmittedUserMessage({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
message,
|
||||
});
|
||||
const submissions = beginMessageSubmission(
|
||||
session.messageSubmissions.get(agentId) ?? [],
|
||||
{ clientMessageId: message.clientMessageId },
|
||||
);
|
||||
const messageSubmissions = new Map(session.messageSubmissions);
|
||||
messageSubmissions.set(agentId, submissions);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail:
|
||||
stream.tail === currentTail
|
||||
? session.agentStreamTail
|
||||
: new Map(session.agentStreamTail).set(agentId, stream.tail),
|
||||
agentStreamHead:
|
||||
stream.head === currentHead
|
||||
? session.agentStreamHead
|
||||
: new Map(session.agentStreamHead).set(agentId, stream.head),
|
||||
messageSubmissions,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
acceptAgentMessageSubmission: (serverId, agentId, clientMessageId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
||||
const submissions = acceptMessageSubmission(currentSubmissions, clientMessageId);
|
||||
if (submissions === currentSubmissions) return prev;
|
||||
const messageSubmissions = new Map(session.messageSubmissions);
|
||||
if (submissions.length > 0) {
|
||||
messageSubmissions.set(agentId, submissions);
|
||||
} else {
|
||||
messageSubmissions.delete(agentId);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, messageSubmissions },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
rejectAgentMessageSubmission: (serverId, agentId, clientMessageId) => {
|
||||
let outcome: MessageSubmissionRejectionOutcome = "unknown";
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
const currentTail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const currentHead = session.agentStreamHead.get(agentId) ?? [];
|
||||
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
||||
const result = rejectMessageSubmission(currentSubmissions, clientMessageId);
|
||||
outcome = result.outcome;
|
||||
if (outcome === "unknown") return prev;
|
||||
const stream =
|
||||
outcome === "rejected"
|
||||
? removeSubmittedUserMessage({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
clientMessageId,
|
||||
})
|
||||
: { tail: currentTail, head: currentHead };
|
||||
const messageSubmissions = new Map(session.messageSubmissions);
|
||||
if (result.submissions.length > 0) {
|
||||
messageSubmissions.set(agentId, result.submissions);
|
||||
} else {
|
||||
messageSubmissions.delete(agentId);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail:
|
||||
stream.tail === currentTail
|
||||
? session.agentStreamTail
|
||||
: new Map(session.agentStreamTail).set(agentId, stream.tail),
|
||||
agentStreamHead:
|
||||
stream.head === currentHead
|
||||
? session.agentStreamHead
|
||||
: new Map(session.agentStreamHead).set(agentId, stream.head),
|
||||
messageSubmissions,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
return outcome;
|
||||
},
|
||||
|
||||
handoffCreatedAgentUserMessage: (serverId, agentId, message) => {
|
||||
let didHandoff = false;
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const currentTail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const currentHead = session.agentStreamHead.get(agentId) ?? [];
|
||||
const result = handoffCreatedAgentUserMessageToStream({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
message,
|
||||
});
|
||||
if (!result.changedTail && !result.changedHead) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const nextTail = result.changedTail
|
||||
? new Map(session.agentStreamTail).set(agentId, result.tail)
|
||||
: session.agentStreamTail;
|
||||
const nextHead = result.changedHead
|
||||
? new Map(session.agentStreamHead).set(agentId, result.head)
|
||||
: session.agentStreamHead;
|
||||
didHandoff = true;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail: nextTail,
|
||||
agentStreamHead: nextHead,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
return didHandoff;
|
||||
},
|
||||
|
||||
clearAgentStreamHead: (serverId, agentId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
if (!session.agentStreamHead.has(agentId)) {
|
||||
return prev;
|
||||
}
|
||||
const nextHead = new Map(session.agentStreamHead);
|
||||
nextHead.delete(agentId);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentStreamHead: nextHead },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineCursor: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextState =
|
||||
typeof state === "function" ? state(session.agentTimelineCursor) : state;
|
||||
if (session.agentTimelineCursor === nextState) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentTimelineCursor: nextState },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineHasOlder: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextState =
|
||||
typeof state === "function" ? state(session.agentTimelineHasOlder) : state;
|
||||
if (session.agentTimelineHasOlder === nextState) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentTimelineHasOlder: nextState },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineHasNewer: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
const nextState =
|
||||
typeof state === "function" ? state(session.agentTimelineHasNewer) : state;
|
||||
if (session.agentTimelineHasNewer === nextState) return prev;
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentTimelineHasNewer: nextState },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineOlderFetchInFlight: (serverId, state) => {
|
||||
set((prev) => {
|
||||
@@ -1321,107 +914,58 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
markAgentHistorySynchronized: (serverId, agentId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const currentGeneration = session.historySyncGeneration;
|
||||
const previousGeneration = session.agentHistorySyncGeneration.get(agentId);
|
||||
if (previousGeneration === currentGeneration) {
|
||||
return prev;
|
||||
}
|
||||
const nextMap = new Map(session.agentHistorySyncGeneration);
|
||||
nextMap.set(agentId, currentGeneration);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentHistorySyncGeneration: nextMap,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentAuthoritativeHistoryApplied: (serverId, agentId, applied) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const previousApplied = session.agentAuthoritativeHistoryApplied.get(agentId) ?? false;
|
||||
if (previousApplied === applied) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const nextApplied = new Map(session.agentAuthoritativeHistoryApplied);
|
||||
if (applied) {
|
||||
nextApplied.set(agentId, true);
|
||||
} else {
|
||||
nextApplied.delete(agentId);
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentAuthoritativeHistoryApplied: nextApplied,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
applyAgentTimelineResponseState: (serverId, agentId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
|
||||
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 = <T>(map: Map<string, T>, value: T | undefined): Map<string, T> => {
|
||||
if (map.get(agentId) === value) return map;
|
||||
changed = true;
|
||||
const next = new Map(map);
|
||||
if (value === undefined) next.delete(agentId);
|
||||
else next.set(agentId, value);
|
||||
return next;
|
||||
};
|
||||
const currentTail = session.agentStreamTail.get(agentId);
|
||||
const items =
|
||||
currentTail?.length === 0 && state.items.length === 0 ? currentTail : state.items;
|
||||
const nextTail = update(session.agentStreamTail, items);
|
||||
const nextHead = update(
|
||||
session.agentStreamHead,
|
||||
state.head.length > 0 ? state.head : undefined,
|
||||
);
|
||||
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<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
removeAgentTimeline: (serverId, agentId) => {
|
||||
set((previous) => {
|
||||
const session = previous.sessions[serverId];
|
||||
if (!session) return previous;
|
||||
const remove = <T>(map: Map<string, T>): Map<string, T> => {
|
||||
const next = new Map(map);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
};
|
||||
return {
|
||||
...previous,
|
||||
sessions: {
|
||||
...previous.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail: remove(session.agentStreamTail),
|
||||
agentStreamHead: remove(session.agentStreamHead),
|
||||
agentTimelineCursor: remove(session.agentTimelineCursor),
|
||||
agentTimelineHasOlder: remove(session.agentTimelineHasOlder),
|
||||
agentTimelineHasNewer: remove(session.agentTimelineHasNewer),
|
||||
agentTimelineOlderFetchInFlight: remove(session.agentTimelineOlderFetchInFlight),
|
||||
agentAuthoritativeHistoryApplied: remove(session.agentAuthoritativeHistoryApplied),
|
||||
agentHistorySyncGeneration: remove(session.agentHistorySyncGeneration),
|
||||
messageSubmissions: remove(session.messageSubmissions),
|
||||
agentTasks: remove(session.agentTasks),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (serverId, state) => {
|
||||
set((prev) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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<ReturnType<DaemonClient["fetchAgentTimeline"]>>;
|
||||
|
||||
test("concurrent identical timeline reads share one request", async () => {
|
||||
let resolvePage: (page: TimelinePage) => void = () => {};
|
||||
const page = new Promise<TimelinePage>((resolve) => {
|
||||
resolvePage = resolve;
|
||||
});
|
||||
const requests: Array<{ agentId: string; direction: string }> = [];
|
||||
const client = {
|
||||
fetchAgentTimeline: async (
|
||||
agentId: string,
|
||||
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
|
||||
) => {
|
||||
requests.push({ agentId, direction: request?.direction ?? "tail" });
|
||||
return page;
|
||||
},
|
||||
};
|
||||
const request = { direction: "tail" as const, limit: 100, projection: "projected" as const };
|
||||
|
||||
const first = fetchAgentTimelineOnce(client, "agent", request);
|
||||
const second = fetchAgentTimelineOnce(client, "agent", request);
|
||||
resolvePage({ hasNewer: false } as TimelinePage);
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
|
||||
expect(requests).toEqual([{ agentId: "agent", direction: "tail" }]);
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
type TimelineClient = Pick<DaemonClient, "fetchAgentTimeline">;
|
||||
type TimelineRequest = Parameters<TimelineClient["fetchAgentTimeline"]>[1];
|
||||
type TimelinePage = Awaited<ReturnType<TimelineClient["fetchAgentTimeline"]>>;
|
||||
|
||||
const inFlightByClient = new WeakMap<object, Map<string, Promise<TimelinePage>>>();
|
||||
|
||||
export function fetchAgentTimelineOnce(
|
||||
client: TimelineClient,
|
||||
agentId: string,
|
||||
request: TimelineRequest,
|
||||
): Promise<TimelinePage> {
|
||||
let inFlight = inFlightByClient.get(client);
|
||||
if (!inFlight) {
|
||||
inFlight = new Map();
|
||||
inFlightByClient.set(client, inFlight);
|
||||
}
|
||||
|
||||
const key = `${agentId}:${JSON.stringify(request)}`;
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const fetch = client.fetchAgentTimeline(agentId, request);
|
||||
inFlight.set(key, fetch);
|
||||
const clear = () => {
|
||||
if (inFlight.get(key) === fetch) inFlight.delete(key);
|
||||
};
|
||||
void fetch.then(clear, clear);
|
||||
return fetch;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<TimelinePageResult>((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<CachedTimeline>((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],
|
||||
|
||||
@@ -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<void> | undefined;
|
||||
}
|
||||
|
||||
export interface TimelineReplicaStorage {
|
||||
removeTimeline(serverId: string, agentId: string): void;
|
||||
readTimeline(serverId: string, agentId: string): Promise<CachedTimeline | undefined>;
|
||||
commitTimeline(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
timeline: CachedTimeline,
|
||||
requireCertified?: boolean,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface TimelineRows {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
}
|
||||
|
||||
interface VerifiedTimeline extends TimelineRows {
|
||||
range: AgentTimelineCursorState | null;
|
||||
hasOlder: boolean;
|
||||
}
|
||||
|
||||
interface AgentTimeline extends TimelineRows {
|
||||
// Pagination and coverage certainty do not change which older entries belong to the restart page.
|
||||
restartStartSeq: number | null;
|
||||
verified: VerifiedTimeline | null;
|
||||
// Loaded positions support navigation even when the displayed content is uncertifiable.
|
||||
displayRange: AgentTimelineCursorState | null;
|
||||
older: boolean;
|
||||
newer: boolean;
|
||||
synchronized: boolean;
|
||||
hasUnsequencedHistory: boolean;
|
||||
submissions: MessageSubmissionRecord[];
|
||||
preparation?: Promise<void>;
|
||||
restored: boolean;
|
||||
liveEpoch?: string;
|
||||
}
|
||||
|
||||
interface PageEffects {
|
||||
recoverGap(
|
||||
agentId: string,
|
||||
cursor: { epoch: string; endSeq: number },
|
||||
observedSeq?: number,
|
||||
): void;
|
||||
drainQueuedAgentMessage(agentId: string): void;
|
||||
}
|
||||
|
||||
function acceptLiveDisplayEpoch(
|
||||
state: AgentTimeline,
|
||||
events: AgentStreamReducerEvent[],
|
||||
): AgentStreamReducerEvent[] {
|
||||
if (state.synchronized) return events;
|
||||
// A connection's live epoch owns the display even while its first page is pending. The saved
|
||||
// epoch remains a restart baseline until a page establishes a replacement.
|
||||
const epoch = events.findLast((event) => event.event.type === "timeline" && event.epoch)?.epoch;
|
||||
if (!epoch) return events;
|
||||
const previousEpoch = state.liveEpoch ?? state.displayRange?.epoch;
|
||||
if (previousEpoch && previousEpoch !== epoch) {
|
||||
state.tail = state.tail.filter(
|
||||
(item) => item.kind === "user_message" && isUnreconciledLocalUserMessage(item),
|
||||
);
|
||||
state.head = state.head.filter(
|
||||
(item) => item.kind === "user_message" && isUnreconciledLocalUserMessage(item),
|
||||
);
|
||||
state.older = false;
|
||||
state.displayRange = null;
|
||||
state.restartStartSeq = null;
|
||||
state.hasUnsequencedHistory = false;
|
||||
}
|
||||
state.liveEpoch = epoch;
|
||||
return events.filter((event) => !event.epoch || event.epoch === epoch);
|
||||
}
|
||||
|
||||
function applyVerifiedStreamEvents(
|
||||
state: AgentTimeline,
|
||||
events: AgentStreamReducerEvent[],
|
||||
display: ReturnType<typeof processAgentStreamEvents>,
|
||||
): boolean {
|
||||
const previous = state.verified;
|
||||
const positioned = events.filter(
|
||||
({ event, seq, epoch }) =>
|
||||
event.type !== "timeline" || (typeof seq === "number" && typeof epoch === "string"),
|
||||
);
|
||||
const unsequencedChanged =
|
||||
positioned.length !== events.length && (display.changedTail || display.changedHead);
|
||||
if (unsequencedChanged) state.hasUnsequencedHistory = true;
|
||||
if (!previous || !state.synchronized) return unsequencedChanged;
|
||||
const accepted =
|
||||
positioned.length === events.length &&
|
||||
state.tail === previous.tail &&
|
||||
state.head === previous.head
|
||||
? display
|
||||
: processAgentStreamEvents({
|
||||
events: positioned,
|
||||
currentTail: previous.tail,
|
||||
currentHead: previous.head,
|
||||
currentCursor: previous.range ?? undefined,
|
||||
hasAuthoritativeBaseline: true,
|
||||
isDetached: state.newer,
|
||||
});
|
||||
const cursor = accepted.cursor;
|
||||
const changedEpoch =
|
||||
cursor !== null && previous.range !== null && cursor.epoch !== previous.range.epoch;
|
||||
if (changedEpoch) {
|
||||
Object.assign(display, retainPendingSubmissions(display, state));
|
||||
if (accepted !== display) Object.assign(accepted, retainPendingSubmissions(accepted, state));
|
||||
state.older = false;
|
||||
state.hasUnsequencedHistory = unsequencedChanged;
|
||||
state.restartStartSeq = cursor.startSeq;
|
||||
}
|
||||
state.verified = {
|
||||
tail: accepted.tail,
|
||||
head: accepted.head,
|
||||
range: accepted.cursorChanged ? cursor : previous.range,
|
||||
hasOlder: changedEpoch ? false : previous.hasOlder,
|
||||
};
|
||||
return (
|
||||
unsequencedChanged || accepted.changedTail || accepted.changedHead || accepted.cursorChanged
|
||||
);
|
||||
}
|
||||
|
||||
function applySubmissionRows(
|
||||
state: AgentTimeline,
|
||||
transition: (rows: TimelineRows) => TimelineRows,
|
||||
): boolean {
|
||||
const verified = state.verified;
|
||||
const shared = verified && state.tail === verified.tail && state.head === verified.head;
|
||||
const result = transition(state);
|
||||
const changed = result.tail !== state.tail || result.head !== state.head;
|
||||
if (verified) {
|
||||
const accepted = shared ? result : transition(verified);
|
||||
verified.tail = accepted.tail;
|
||||
verified.head = accepted.head;
|
||||
}
|
||||
state.tail = result.tail;
|
||||
state.head = result.head;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function isSupersededTail(
|
||||
payload: TimelineResponsePayload,
|
||||
current: AgentTimelineCursorState | null | undefined,
|
||||
requestedFrom: { epoch: string; endSeq: number } | null | undefined,
|
||||
): boolean {
|
||||
if (payload.direction !== "tail" || payload.reset || payload.mergeWindow || payload.error)
|
||||
return false;
|
||||
// A head already behind the request's accepted cursor demonstrates a rewind. A head
|
||||
// passed by activity accepted during that request is an obsolete reply instead.
|
||||
return (
|
||||
requestedFrom !== undefined &&
|
||||
(requestedFrom === null ||
|
||||
requestedFrom.epoch !== payload.epoch ||
|
||||
payload.window.maxSeq >= requestedFrom.endSeq) &&
|
||||
current?.epoch === payload.epoch &&
|
||||
payload.window.maxSeq < current.endSeq
|
||||
);
|
||||
}
|
||||
|
||||
function invalidatesDurableBaseline(
|
||||
payload: TimelineResponsePayload,
|
||||
result: ProcessTimelineResponseOutput,
|
||||
baseline: VerifiedTimeline | null,
|
||||
): boolean {
|
||||
const completeReplacement =
|
||||
payload.direction === "tail" &&
|
||||
(payload.window.maxSeq < payload.window.minSeq ||
|
||||
(payload.startCursor?.seq === payload.window.minSeq &&
|
||||
payload.endCursor?.seq === payload.window.maxSeq));
|
||||
return Boolean(
|
||||
result.commit === "apply" &&
|
||||
completeReplacement &&
|
||||
(payload.reset ||
|
||||
(baseline?.range &&
|
||||
(payload.epoch !== baseline.range.epoch || payload.window.maxSeq < baseline.range.endSeq))),
|
||||
);
|
||||
}
|
||||
|
||||
function applyAcceptedPageState(input: {
|
||||
state: AgentTimeline;
|
||||
payload: TimelineResponsePayload;
|
||||
result: ProcessTimelineResponseOutput;
|
||||
verified: VerifiedTimeline;
|
||||
replaceTail: boolean;
|
||||
synchronized: boolean;
|
||||
}): void {
|
||||
const { state, payload, result, verified, synchronized } = input;
|
||||
const baseline = state.verified;
|
||||
const range = verified.range;
|
||||
const replacesWindow =
|
||||
input.replaceTail ||
|
||||
payload.reset ||
|
||||
!baseline?.range ||
|
||||
!range ||
|
||||
baseline.range.epoch !== range.epoch ||
|
||||
range.endSeq < baseline.range.endSeq;
|
||||
// Older pagination can extend an existing baseline, but cannot establish live coverage.
|
||||
if (payload.direction !== "before" || baseline) {
|
||||
if (replacesWindow) state.restartStartSeq = range?.startSeq ?? null;
|
||||
state.verified = verified;
|
||||
if (payload.direction !== "before") state.hasUnsequencedHistory = false;
|
||||
}
|
||||
state.tail = result.tail;
|
||||
state.head = result.head;
|
||||
if (result.cursorChanged) state.displayRange = result.cursor ?? null;
|
||||
if (result.older !== "unchanged") state.older = result.older === "available";
|
||||
if (payload.direction !== "before") state.newer = payload.hasNewer;
|
||||
if (synchronized) state.liveEpoch = payload.epoch;
|
||||
state.synchronized ||= synchronized;
|
||||
state.submissions = observeMessageSubmissionCanonical(
|
||||
state.submissions,
|
||||
result.acknowledgedClientMessageIds,
|
||||
);
|
||||
}
|
||||
|
||||
/** Host-lived write authority. Zustand is its UI projection; disk is write-behind. */
|
||||
export class TimelineReplica {
|
||||
private readonly agents = new Map<string, AgentTimeline>();
|
||||
private readonly removed = new Set<string>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private serverId: string,
|
||||
private readonly storage: TimelineReplicaStorage,
|
||||
) {}
|
||||
|
||||
private state(agentId: string): AgentTimeline {
|
||||
const existing = this.agents.get(agentId);
|
||||
if (existing) return existing;
|
||||
const state: AgentTimeline = {
|
||||
tail: [],
|
||||
head: [],
|
||||
older: false,
|
||||
newer: false,
|
||||
synchronized: false,
|
||||
hasUnsequencedHistory: false,
|
||||
restored: false,
|
||||
restartStartSeq: null,
|
||||
verified: null,
|
||||
displayRange: null,
|
||||
submissions: [],
|
||||
};
|
||||
this.agents.set(agentId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private accepts(agentId: string): boolean {
|
||||
return !this.disposed && !this.removed.has(agentId);
|
||||
}
|
||||
|
||||
prepare(agentId: string): Promise<void> {
|
||||
if (!this.accepts(agentId)) return Promise.resolve();
|
||||
const state = this.state(agentId);
|
||||
state.preparation ??= this.restore(agentId, state);
|
||||
return state.preparation;
|
||||
}
|
||||
|
||||
private async restore(agentId: string, state: AgentTimeline): Promise<void> {
|
||||
const serverId = this.serverId;
|
||||
const stored = await this.storage.readTimeline(serverId, agentId);
|
||||
if (!this.accepts(agentId) || this.agents.get(agentId) !== state) return;
|
||||
// Identity transfer retires the old scoped read, even if it captured rows before rename.
|
||||
if (serverId !== this.serverId) return this.restore(agentId, state);
|
||||
state.restored = true;
|
||||
if (state.verified || !stored) {
|
||||
this.publish(agentId, state, {
|
||||
persist: Boolean(state.verified) || state.hasUnsequencedHistory,
|
||||
requireCertified: Boolean(stored?.range),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const sameEpoch = !state.liveEpoch || !stored.range || state.liveEpoch === stored.range.epoch;
|
||||
const replacement = replaceWithCanonicalStream({
|
||||
canonical: sameEpoch ? stored.items : [],
|
||||
previousTail: state.tail,
|
||||
previousHead: state.head,
|
||||
sendingClientMessageIds: getSendingClientMessageIds(state.submissions),
|
||||
preserveContinuity: true,
|
||||
canonicalCoverage:
|
||||
sameEpoch && stored.range
|
||||
? { epoch: stored.range.epoch, endSeq: stored.range.endSeq }
|
||||
: "rows-only",
|
||||
});
|
||||
Object.assign(state, {
|
||||
tail: replacement.tail,
|
||||
head: replacement.head,
|
||||
older: sameEpoch && stored.hasOlder,
|
||||
displayRange: sameEpoch ? stored.range : null,
|
||||
});
|
||||
if (stored.range) {
|
||||
state.restartStartSeq = stored.range.startSeq;
|
||||
state.verified = {
|
||||
tail: stored.items,
|
||||
head: [],
|
||||
range: stored.range,
|
||||
hasOlder: stored.hasOlder,
|
||||
};
|
||||
}
|
||||
state.submissions = observeMessageSubmissionCanonical(
|
||||
state.submissions,
|
||||
replacement.acknowledgedClientMessageIds,
|
||||
);
|
||||
this.publish(agentId, state, {
|
||||
persist: state.hasUnsequencedHistory,
|
||||
requireCertified: Boolean(stored.range),
|
||||
});
|
||||
}
|
||||
|
||||
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined {
|
||||
if (!this.accepts(agentId)) return undefined;
|
||||
const range = this.state(agentId).verified?.range;
|
||||
return range ? { epoch: range.epoch, endSeq: range.endSeq } : undefined;
|
||||
}
|
||||
|
||||
private streamSnapshot(agentId: string): AgentStreamReducerSnapshot {
|
||||
const state = this.state(agentId);
|
||||
return {
|
||||
currentTail: state.tail,
|
||||
currentHead: state.head,
|
||||
currentCursor: state.displayRange ?? undefined,
|
||||
// Before network synchronization, live activity can paint ahead of the saved range.
|
||||
hasAuthoritativeBaseline: state.synchronized,
|
||||
isDetached: state.synchronized && state.newer,
|
||||
};
|
||||
}
|
||||
|
||||
applyEvents(
|
||||
agentId: string,
|
||||
events: AgentStreamReducerEvent[],
|
||||
recoverGap: PageEffects["recoverGap"],
|
||||
): void {
|
||||
if (!this.accepts(agentId)) return;
|
||||
const state = this.state(agentId);
|
||||
events = acceptLiveDisplayEpoch(state, events);
|
||||
const display = processAgentStreamEvents({ events, ...this.streamSnapshot(agentId) });
|
||||
const previous = state.verified;
|
||||
const persist = applyVerifiedStreamEvents(state, events, display);
|
||||
if (display.cursorChanged) state.displayRange = display.cursor;
|
||||
state.tail = display.tail;
|
||||
state.head = display.head;
|
||||
state.submissions = observeMessageSubmissionCanonical(
|
||||
state.submissions,
|
||||
display.acknowledgedClientMessageIds,
|
||||
);
|
||||
this.publish(agentId, state, {
|
||||
persist: persist || !previous,
|
||||
requireCertified: Boolean(previous?.range && state.verified),
|
||||
});
|
||||
for (const effect of display.sideEffects) {
|
||||
if (effect.type === "catch_up") recoverGap(agentId, effect.cursor, effect.observedSeq);
|
||||
}
|
||||
}
|
||||
|
||||
applyPage(
|
||||
payload: TimelineResponsePayload,
|
||||
effects: PageEffects,
|
||||
request?: TimelinePageRequest,
|
||||
): void {
|
||||
const agentId = payload.agentId;
|
||||
if (!this.accepts(agentId)) return;
|
||||
const state = this.state(agentId);
|
||||
const initKey = getInitKey(this.serverId, agentId);
|
||||
const deferred = getInitDeferred(initKey);
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
const ownsInitialization = !request || request.initialization === deferred?.promise;
|
||||
const common = {
|
||||
payload,
|
||||
replaceTail: request?.replaceTail === true,
|
||||
currentCursor: state.displayRange ?? undefined,
|
||||
isInitializing: ownsInitialization && session?.initializingAgents.get(agentId) === true,
|
||||
hasActiveInitDeferred: ownsInitialization && Boolean(deferred),
|
||||
initRequestDirection: deferred?.requestDirection ?? ("tail" as const),
|
||||
sendingClientMessageIds: getSendingClientMessageIds(state.submissions),
|
||||
};
|
||||
if (isSupersededTail(payload, state.verified?.range, request?.cursor)) {
|
||||
const completion = processTimelineResponseCompletion(common);
|
||||
settleTimelineInitialization(this.serverId, agentId, completion);
|
||||
return;
|
||||
}
|
||||
const result = processTimelineResponse({
|
||||
...common,
|
||||
currentTail: state.tail,
|
||||
currentHead: state.head,
|
||||
});
|
||||
if (result.error) {
|
||||
settleTimelineInitialization(this.serverId, agentId, result);
|
||||
return;
|
||||
}
|
||||
const baseline = state.verified;
|
||||
const invalidated = invalidatesDurableBaseline(payload, result, baseline);
|
||||
if (invalidated) {
|
||||
this.storage.removeTimeline(this.serverId, agentId);
|
||||
state.restored = true;
|
||||
}
|
||||
const synchronized = isTimelineResumeSnapshotAuthoritative({
|
||||
direction: payload.direction,
|
||||
hasNewer: payload.hasNewer,
|
||||
error: payload.error,
|
||||
});
|
||||
applyAcceptedPageState({
|
||||
state,
|
||||
payload,
|
||||
result,
|
||||
verified: reduceVerifiedPage(state, common, result),
|
||||
replaceTail: common.replaceTail,
|
||||
synchronized,
|
||||
});
|
||||
this.publish(agentId, state, {
|
||||
persist: payload.direction !== "before",
|
||||
requireCertified: !invalidated && Boolean(baseline?.range),
|
||||
synchronized,
|
||||
});
|
||||
if (!this.accepts(agentId)) return;
|
||||
recoverDisplayedGap(agentId, state, effects.recoverGap);
|
||||
finalizeProcessedTimeline({
|
||||
serverId: this.serverId,
|
||||
agentId,
|
||||
result,
|
||||
synchronized,
|
||||
...effects,
|
||||
});
|
||||
}
|
||||
|
||||
beginSubmission(agentId: string, message: UserMessageItem, tracked: boolean): void {
|
||||
if (!this.accepts(agentId)) return;
|
||||
const state = this.state(agentId);
|
||||
if (tracked) {
|
||||
if (!message.clientMessageId)
|
||||
throw new Error("Beginning a message submission requires client identity");
|
||||
state.submissions = beginMessageSubmission(state.submissions, {
|
||||
clientMessageId: message.clientMessageId,
|
||||
});
|
||||
}
|
||||
applySubmissionRows(state, (rows) => appendSubmittedUserMessage({ ...rows, message }));
|
||||
this.publish(agentId, state);
|
||||
}
|
||||
|
||||
acceptSubmission(agentId: string, clientMessageId: string): void {
|
||||
if (!this.accepts(agentId)) return;
|
||||
const state = this.state(agentId);
|
||||
state.submissions = acceptMessageSubmission(state.submissions, clientMessageId);
|
||||
this.publish(agentId, state);
|
||||
}
|
||||
|
||||
rejectSubmission(
|
||||
agentId: string,
|
||||
clientMessageId: string,
|
||||
tracked: boolean,
|
||||
): MessageSubmissionRejectionOutcome {
|
||||
if (!this.accepts(agentId)) return "unknown";
|
||||
const state = this.state(agentId);
|
||||
const rejection = rejectMessageSubmission(state.submissions, clientMessageId);
|
||||
const outcome = tracked ? rejection.outcome : "rejected";
|
||||
state.submissions = rejection.submissions;
|
||||
if (outcome === "rejected") {
|
||||
applySubmissionRows(state, (rows) =>
|
||||
removeSubmittedUserMessage({ ...rows, clientMessageId }),
|
||||
);
|
||||
}
|
||||
this.publish(agentId, state);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
handoffSubmission(agentId: string, message: UserMessageItem): boolean {
|
||||
if (!this.accepts(agentId)) return false;
|
||||
const state = this.state(agentId);
|
||||
const changed = applySubmissionRows(state, (rows) =>
|
||||
handoffCreatedAgentUserMessageToStream({ ...rows, message }),
|
||||
);
|
||||
if (!changed) return false;
|
||||
this.publish(agentId, state, {
|
||||
persist: Boolean(state.verified),
|
||||
requireCertified: Boolean(state.verified?.range),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
acceptAgent(agentId: string): void {
|
||||
if (!this.disposed) this.removed.delete(agentId);
|
||||
}
|
||||
|
||||
remove(agentId: string, reason: AgentRemovalReason): void {
|
||||
this.removed.add(agentId);
|
||||
this.agents.delete(agentId);
|
||||
if (reason === "deleted") this.storage.removeTimeline(this.serverId, agentId);
|
||||
useSessionStore.getState().removeAgentTimeline(this.serverId, agentId);
|
||||
}
|
||||
|
||||
reconcileServerId(serverId: string): void {
|
||||
this.serverId = serverId;
|
||||
for (const [agentId, state] of this.agents) {
|
||||
this.publish(agentId, state, { synchronized: state.synchronized });
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.agents.clear();
|
||||
}
|
||||
|
||||
private publish(
|
||||
agentId: string,
|
||||
state: AgentTimeline,
|
||||
options: { persist?: boolean; requireCertified?: boolean; synchronized?: boolean } = {},
|
||||
): void {
|
||||
const { persist = false, requireCertified = false, synchronized = false } = options;
|
||||
// Unknown display content cannot certify disk, but does not revoke known source coverage.
|
||||
// While positioned catch-up is incomplete, keep its accepted durable baseline instead.
|
||||
const displayOnly = state.hasUnsequencedHistory && state.synchronized && !state.newer;
|
||||
const verified = displayOnly ? null : state.verified;
|
||||
const range = verified?.range ?? null;
|
||||
const durable: CachedTimeline | null =
|
||||
persist && (verified !== null || state.restored)
|
||||
? {
|
||||
agentId,
|
||||
items: verified ? [...verified.tail, ...verified.head] : [...state.tail, ...state.head],
|
||||
range,
|
||||
hasOlder: Boolean(verified?.hasOlder),
|
||||
}
|
||||
: null;
|
||||
// Both outputs are determined before subscribers run; none can authorize persistence by
|
||||
// changing a mutable store flag between publication and the write-behind decision.
|
||||
if (durable) {
|
||||
const page = latestRestartPage(durable, state.restartStartSeq);
|
||||
if (page.range) state.restartStartSeq = page.range.startSeq;
|
||||
this.storage.commitTimeline(
|
||||
this.serverId,
|
||||
agentId,
|
||||
page,
|
||||
(requireCertified && !displayOnly) || !state.restored,
|
||||
);
|
||||
}
|
||||
useSessionStore.getState().applyAgentTimelineResponseState(this.serverId, agentId, {
|
||||
items: state.tail,
|
||||
head: state.head,
|
||||
range: state.displayRange,
|
||||
older: state.older ? "available" : "none",
|
||||
newer: state.newer,
|
||||
synchronized,
|
||||
submissions: state.submissions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function latestRestartPage(
|
||||
timeline: CachedTimeline,
|
||||
restartStartSeq: number | null,
|
||||
): CachedTimeline {
|
||||
const startOf = (item: StreamItem | undefined) =>
|
||||
item?.source?.startSeq ?? item?.timelineCursor?.seq;
|
||||
const candidates = timeline.items.filter(
|
||||
(item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item),
|
||||
);
|
||||
const items = candidates.filter(
|
||||
(item) =>
|
||||
restartStartSeq === null ||
|
||||
(startOf(item) ?? restartStartSeq) >= restartStartSeq ||
|
||||
// A prepend may complete the retained boundary message. Keep that whole item,
|
||||
// without admitting older tools merely because they finish inside the window.
|
||||
item.source?.chunks?.some((chunk) => chunk.seq === restartStartSeq),
|
||||
);
|
||||
const first = Math.max(0, items.length - TIMELINE_FETCH_PAGE_SIZE);
|
||||
const omitted = candidates.length > items.length || first > 0;
|
||||
const firstSource = startOf(items[first]);
|
||||
const floor = restartStartSeq ?? timeline.range?.startSeq;
|
||||
const startSeq = first > 0 ? firstSource : Math.min(floor ?? Infinity, firstSource ?? Infinity);
|
||||
const range =
|
||||
timeline.range && startSeq !== undefined && Number.isFinite(startSeq)
|
||||
? { ...timeline.range, startSeq: Math.max(timeline.range.startSeq, startSeq) }
|
||||
: timeline.range;
|
||||
return {
|
||||
...timeline,
|
||||
items: items.slice(first),
|
||||
range,
|
||||
hasOlder:
|
||||
timeline.hasOlder ||
|
||||
omitted ||
|
||||
Boolean(range && timeline.range && range.startSeq > timeline.range.startSeq),
|
||||
};
|
||||
}
|
||||
|
||||
export function createTimelineReplica(input: {
|
||||
serverId: string;
|
||||
storage: TimelineReplicaStorage;
|
||||
}): TimelineReplica {
|
||||
return new TimelineReplica(input.serverId, input.storage);
|
||||
}
|
||||
function retainPendingSubmissions(rows: TimelineRows, state: AgentTimeline): TimelineRows {
|
||||
const pending = getSendingClientMessageIds(state.submissions);
|
||||
for (const row of [...state.tail, ...state.head]) {
|
||||
if (
|
||||
row.kind !== "user_message" ||
|
||||
!row.clientMessageId ||
|
||||
!pending.includes(row.clientMessageId)
|
||||
)
|
||||
continue;
|
||||
rows = handoffCreatedAgentUserMessageToStream({ ...rows, message: row });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function reduceVerifiedPage(
|
||||
state: AgentTimeline,
|
||||
common: Omit<Parameters<typeof processTimelineResponse>[0], "currentTail" | "currentHead">,
|
||||
display: ProcessTimelineResponseOutput,
|
||||
): VerifiedTimeline {
|
||||
const baseline = state.verified;
|
||||
const currentCursor = baseline?.range ?? undefined;
|
||||
const shared =
|
||||
(baseline === null && state.tail.length === 0 && state.head.length === 0) ||
|
||||
(baseline?.tail === state.tail && baseline.head === state.head);
|
||||
let rows: TimelineRows = baseline ?? { tail: [], head: [] };
|
||||
for (const row of shared ? [] : [...state.tail, ...state.head]) {
|
||||
if (row.kind === "user_message" && isUnreconciledLocalUserMessage(row)) {
|
||||
rows = handoffCreatedAgentUserMessageToStream({ ...rows, message: row });
|
||||
}
|
||||
}
|
||||
const verified = shared
|
||||
? display
|
||||
: processTimelineResponse({
|
||||
...common,
|
||||
currentCursor,
|
||||
currentTail: rows.tail,
|
||||
currentHead: rows.head,
|
||||
});
|
||||
// Divergence ends when the accepted page reconciles the overlay. Subsequent events share
|
||||
// one reduction again; no consumer or synchronization flag decides row identity.
|
||||
if (!shared && equal([...verified.tail, ...verified.head], [...display.tail, ...display.head])) {
|
||||
verified.tail = display.tail;
|
||||
verified.head = display.head;
|
||||
}
|
||||
const range = verified.cursorChanged ? (verified.cursor ?? null) : (currentCursor ?? null);
|
||||
return {
|
||||
tail: verified.tail,
|
||||
head: verified.head,
|
||||
range,
|
||||
hasOlder: verified.older === "unchanged" ? state.older : verified.older === "available",
|
||||
};
|
||||
}
|
||||
|
||||
function recoverDisplayedGap(
|
||||
agentId: string,
|
||||
state: AgentTimeline,
|
||||
recoverGap: PageEffects["recoverGap"],
|
||||
): void {
|
||||
const range = state.verified?.range;
|
||||
if (!range) return;
|
||||
for (const row of [...state.tail, ...state.head]) {
|
||||
const cursor = row.timelineCursor;
|
||||
if (cursor?.epoch === range.epoch && cursor.seq > range.endSeq)
|
||||
recoverGap(agentId, range, cursor.seq);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAgentInitializingFlag(serverId: string, agentId: string): void {
|
||||
useSessionStore.getState().setInitializingAgents(serverId, (previous) => {
|
||||
if (previous.get(agentId) !== true) return previous;
|
||||
const next = new Map(previous);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function settleTimelineInitialization(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
result: Pick<ProcessTimelineResponseOutput, "clearInitializing" | "initResolution" | "error">,
|
||||
): void {
|
||||
if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId);
|
||||
const key = getInitKey(serverId, agentId);
|
||||
if (result.initResolution === "resolve") resolveInitDeferred(key);
|
||||
if (result.initResolution === "reject" && result.error)
|
||||
rejectInitDeferred(key, new Error(result.error));
|
||||
}
|
||||
|
||||
function finalizeProcessedTimeline(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
result: ProcessTimelineResponseOutput;
|
||||
synchronized: boolean;
|
||||
recoverGap: (
|
||||
agentId: string,
|
||||
cursor: { epoch: string; endSeq: number },
|
||||
observedSeq?: number,
|
||||
) => void;
|
||||
drainQueuedAgentMessage: (agentId: string) => void;
|
||||
}): void {
|
||||
for (const effect of input.result.sideEffects) {
|
||||
if (effect.type === "catch_up") {
|
||||
input.recoverGap(input.agentId, effect.cursor, effect.observedSeq);
|
||||
}
|
||||
}
|
||||
settleTimelineInitialization(input.serverId, input.agentId, input.result);
|
||||
if (input.synchronized) {
|
||||
useCreateFlowStore
|
||||
.getState()
|
||||
.clearByAgent({ serverId: input.serverId, agentId: input.agentId });
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
|
||||
if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId);
|
||||
}
|
||||
}
|
||||
@@ -4,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: () => ({
|
||||
apply: (agentId, events) => {
|
||||
const result = processAgentStreamEvents({
|
||||
events,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor: undefined,
|
||||
}),
|
||||
commit: (agentId, result) => {
|
||||
});
|
||||
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: () => ({
|
||||
apply: (agentId, events) => {
|
||||
const result = processAgentStreamEvents({
|
||||
events,
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
}),
|
||||
commit: (agentId, result) => {
|
||||
});
|
||||
commits.push(
|
||||
`${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`,
|
||||
);
|
||||
},
|
||||
handleSideEffects: () => {},
|
||||
scheduleFlush: scheduler.schedule,
|
||||
cancelFlush: scheduler.cancel,
|
||||
});
|
||||
@@ -4416,17 +4507,17 @@ describe("createAgentStreamReducerQueue", () => {
|
||||
let currentCursor: TimelineCursor | undefined;
|
||||
|
||||
const queue = createAgentStreamReducerQueue({
|
||||
getSnapshot: () => ({
|
||||
apply: (_agentId, events) => {
|
||||
const result = processAgentStreamEvents({
|
||||
events,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
}),
|
||||
commit: (_agentId, result) => {
|
||||
});
|
||||
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: () => ({
|
||||
apply: (agentId, events) => {
|
||||
const result = processAgentStreamEvents({
|
||||
events,
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
}),
|
||||
commit: (agentId, result) => {
|
||||
});
|
||||
commits.push(
|
||||
`${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`,
|
||||
);
|
||||
},
|
||||
handleSideEffects: () => {},
|
||||
scheduleFlush: scheduler.schedule,
|
||||
cancelFlush: scheduler.cancel,
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<T> {
|
||||
promise: Promise<T>;
|
||||
@@ -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<string, { epoch: string; endSeq: number }>();
|
||||
readonly cacheRequests: string[] = [];
|
||||
readonly forcedTimelineTailReplacements = new Set<string>();
|
||||
cacheGate: Deferred<void> | 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);
|
||||
}
|
||||
},
|
||||
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<string> {
|
||||
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<void>();
|
||||
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 });
|
||||
|
||||
@@ -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<CachedTimeline | undefined>;
|
||||
commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void;
|
||||
}
|
||||
|
||||
async function prepareCachedTimeline(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
storage: TimelineReplicaStorage;
|
||||
prepareAgent: (agentId: string) => Promise<void>;
|
||||
}): Promise<CachedTimeline | undefined> {
|
||||
const before = useSessionStore.getState().sessions[input.serverId];
|
||||
const beforeTimeline = selectAgentTimelineState(before, input.agentId);
|
||||
if (beforeTimeline.status === "synced") return undefined;
|
||||
const 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<void>;
|
||||
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
|
||||
readRange(agentId: string): AgentTimelineCursorState | undefined;
|
||||
timelineUpdated(agentId: string): void;
|
||||
}
|
||||
|
||||
class TimelineReplicaOwner implements TimelineReplica {
|
||||
private readonly cachedRanges = new Map<string, AgentTimelineCursorState>();
|
||||
private readonly preparations = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly serverId: string,
|
||||
private readonly storage: TimelineReplicaStorage,
|
||||
private readonly prepareAgent: (agentId: string) => Promise<void>,
|
||||
) {}
|
||||
|
||||
async prepare(agentId: string): Promise<void> {
|
||||
const existing = this.preparations.get(agentId);
|
||||
if (existing) return existing;
|
||||
const preparation = this.load(agentId).finally(() => {
|
||||
if (this.preparations.get(agentId) === preparation) {
|
||||
this.preparations.delete(agentId);
|
||||
}
|
||||
});
|
||||
this.preparations.set(agentId, preparation);
|
||||
return preparation;
|
||||
}
|
||||
|
||||
private async load(agentId: string): Promise<void> {
|
||||
const stored = await prepareCachedTimeline({
|
||||
serverId: this.serverId,
|
||||
agentId,
|
||||
storage: this.storage,
|
||||
prepareAgent: this.prepareAgent,
|
||||
});
|
||||
if (!stored) return;
|
||||
if (stored.range) {
|
||||
this.cachedRanges.set(agentId, stored.range);
|
||||
}
|
||||
}
|
||||
|
||||
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined {
|
||||
const range = this.cachedRanges.get(agentId);
|
||||
return range ? { epoch: range.epoch, endSeq: range.endSeq } : undefined;
|
||||
}
|
||||
|
||||
readRange(agentId: string): AgentTimelineCursorState | undefined {
|
||||
return this.cachedRanges.get(agentId);
|
||||
}
|
||||
|
||||
timelineUpdated(agentId: string): void {
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
if (timeline.status === "cold") return;
|
||||
if (timeline.status === "synced") this.cachedRanges.delete(agentId);
|
||||
this.storage.commitTimeline(this.serverId, agentId, {
|
||||
agentId,
|
||||
items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])],
|
||||
range: timeline.status === "synced" ? timeline.range : null,
|
||||
hasOlder: timeline.status === "synced" && timeline.older === "available",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createTimelineReplica(input: {
|
||||
serverId: string;
|
||||
storage: TimelineReplicaStorage;
|
||||
prepareAgent: (agentId: string) => Promise<void>;
|
||||
}): TimelineReplica {
|
||||
return new TimelineReplicaOwner(input.serverId, input.storage, input.prepareAgent);
|
||||
}
|
||||
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<string>,
|
||||
): TimelineResponsePayload {
|
||||
if (payload.direction !== "tail") return payload;
|
||||
if (!replacements.delete(payload.agentId)) return payload;
|
||||
return { ...payload, reset: true };
|
||||
}
|
||||
|
||||
function clearAgentInitializingFlag(serverId: string, agentId: string): void {
|
||||
useSessionStore.getState().setInitializingAgents(serverId, (previous) => {
|
||||
if (previous.get(agentId) !== true) return previous;
|
||||
const next = new Map(previous);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function commitProcessedTimeline(input: {
|
||||
serverId: string;
|
||||
payload: TimelineResponsePayload;
|
||||
result: ProcessTimelineResponseOutput;
|
||||
timeline: AgentTimelineState;
|
||||
currentCursor: AgentTimelineCursorState | undefined;
|
||||
synchronized: boolean;
|
||||
}): void {
|
||||
const { serverId, payload, result, timeline, currentCursor, synchronized } = input;
|
||||
const agentId = payload.agentId;
|
||||
const store = useSessionStore.getState();
|
||||
if (result.commit !== "discard") {
|
||||
store.applyAgentTimelineResponseState(serverId, agentId, {
|
||||
items: result.tail,
|
||||
head: result.head,
|
||||
range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null),
|
||||
older: result.older,
|
||||
newer:
|
||||
payload.direction === "before"
|
||||
? timeline.status === "synced" && timeline.newer === "available"
|
||||
: payload.hasNewer,
|
||||
synchronized,
|
||||
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.acknowledgedClientMessageIds.length > 0) {
|
||||
store.setAgentStreamState(serverId, agentId, {
|
||||
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
|
||||
});
|
||||
}
|
||||
if (payload.direction !== "before") {
|
||||
store.setAgentTimelineHasNewer(serverId, (current) => {
|
||||
const next = new Map(current);
|
||||
next.set(agentId, payload.hasNewer);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
store.markAgentHistorySynchronized(serverId, agentId);
|
||||
}
|
||||
|
||||
function finalizeProcessedTimeline(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
initKey: string;
|
||||
result: ProcessTimelineResponseOutput;
|
||||
synchronized: boolean;
|
||||
recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
drainQueuedAgentMessage: (agentId: string) => void;
|
||||
}): void {
|
||||
for (const effect of input.result.sideEffects) {
|
||||
if (effect.type === "catch_up") input.recoverGap(input.agentId, effect.cursor);
|
||||
}
|
||||
if (input.result.clearInitializing) {
|
||||
clearAgentInitializingFlag(input.serverId, input.agentId);
|
||||
}
|
||||
if (input.synchronized) {
|
||||
useCreateFlowStore
|
||||
.getState()
|
||||
.clearByAgent({ serverId: input.serverId, agentId: input.agentId });
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
|
||||
if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId);
|
||||
}
|
||||
if (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey);
|
||||
}
|
||||
|
||||
function applyAuthoritativeTimelineResponse(input: {
|
||||
serverId: string;
|
||||
payload: TimelineResponsePayload;
|
||||
cachedCursor?: AgentTimelineCursorState;
|
||||
recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
drainQueuedAgentMessage: (agentId: string) => void;
|
||||
}): boolean {
|
||||
const { serverId, payload } = input;
|
||||
const agentId = payload.agentId;
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
const activeInitDeferred = getInitDeferred(initKey);
|
||||
const currentCursor =
|
||||
timeline.status === "synced" ? (timeline.range ?? undefined) : input.cachedCursor;
|
||||
const result = processTimelineResponse({
|
||||
payload,
|
||||
currentTail: timeline.status === "cold" ? [] : timeline.items,
|
||||
currentHead: session?.agentStreamHead.get(agentId) ?? [],
|
||||
currentCursor,
|
||||
isInitializing: session?.initializingAgents.get(agentId) === true,
|
||||
hasActiveInitDeferred: Boolean(activeInitDeferred),
|
||||
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
|
||||
sendingClientMessageIds: getSendingClientMessageIds(session?.messageSubmissions.get(agentId)),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId);
|
||||
if (result.initResolution === "reject") rejectInitDeferred(initKey, new Error(result.error));
|
||||
return false;
|
||||
}
|
||||
|
||||
const synchronized = isTimelineResumeSnapshotAuthoritative({
|
||||
direction: payload.direction,
|
||||
hasNewer: payload.hasNewer,
|
||||
error: payload.error,
|
||||
});
|
||||
commitProcessedTimeline({ serverId, payload, result, timeline, currentCursor, synchronized });
|
||||
finalizeProcessedTimeline({
|
||||
serverId,
|
||||
agentId,
|
||||
initKey,
|
||||
result,
|
||||
synchronized,
|
||||
recoverGap: input.recoverGap,
|
||||
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface ViewedTimelineSyncPorts {
|
||||
initialDeliveryMode: TimelineDeliveryMode;
|
||||
prepare(agentId: string): Promise<void>;
|
||||
@@ -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<string, string[]>();
|
||||
const sources = new Map<string, VisibleAgentSource>();
|
||||
const catchUps = new Map<string, CatchUpState>();
|
||||
const catchUpGenerations = new Map<string, number>();
|
||||
// Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked.
|
||||
// Acknowledgement and tail completion are the only drain points.
|
||||
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
||||
// The newest live sequence each agent has been told about but whose coverage is not yet
|
||||
// established. A catch-up is complete only when authoritative coverage reaches it; a page
|
||||
// built before that sequence existed cannot settle it, however it was requested.
|
||||
const observedHeads = new Map<string, { epoch: string; seq: number }>();
|
||||
const visibilityCatchUpPending = new Set<string>();
|
||||
const visibilityCatchUpErrors = new Map<string, string>();
|
||||
// User-initiated retries only. Background retries stay silent; a retry the user asked for
|
||||
@@ -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,15 +331,24 @@ 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 (fallbackToLatestTailOnOverflow) {
|
||||
await ports.fetchLatestTail(agentId);
|
||||
catchUps.set(agentId, { generation, status: "complete" });
|
||||
setVisibilityCatchUpReady(agentId);
|
||||
return;
|
||||
if (
|
||||
request.direction === "after" &&
|
||||
page.endCursor.epoch === request.cursor.epoch &&
|
||||
page.endCursor.seq <= request.cursor.seq
|
||||
) {
|
||||
throw new Error(`Timeline page for ${agentId} did not advance its cursor`);
|
||||
}
|
||||
if (fallbackToLatestTailOnOverflow) {
|
||||
cursorBefore = ports.readCursor(agentId);
|
||||
observedBefore = observedHeads.get(agentId);
|
||||
await ports.fetchLatestTail(agentId);
|
||||
if (!ownsCatchUp(agentId, generation)) return;
|
||||
} else {
|
||||
await fetchUntilCurrent(
|
||||
agentId,
|
||||
generation,
|
||||
@@ -574,15 +357,15 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
);
|
||||
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<void> => {
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+442
-131
@@ -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 {
|
||||
const position = item.timelineCursor;
|
||||
if (!position) return false;
|
||||
if (coverage !== "rows-only") {
|
||||
return (
|
||||
position.epoch === coverage.epoch &&
|
||||
(coverage.endSeq === null || position.seq > coverage.endSeq)
|
||||
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,28 +574,52 @@ function reconcileReplacementHeadAgainstTail(
|
||||
};
|
||||
}
|
||||
|
||||
// Positioned rows beyond canonical coverage are daemon history the page did not carry, so they
|
||||
// survive every replacement. Continuity decides only what happens to rows without a position.
|
||||
function retainsAcrossReplacement(
|
||||
item: StreamItem,
|
||||
input: {
|
||||
canonical: readonly StreamItem[];
|
||||
tail: readonly StreamItem[];
|
||||
preserveContinuity: boolean;
|
||||
sendingClientMessageIds: ReadonlySet<string>;
|
||||
canonicalCoverage: CanonicalCoverage;
|
||||
},
|
||||
): boolean {
|
||||
if (item.timelineCursor) {
|
||||
if (!isCoveredByCanonical(item, input.canonical, input.canonicalCoverage)) return true;
|
||||
const canonicalTailAssistant = input.tail.at(-1);
|
||||
return (
|
||||
input.preserveContinuity &&
|
||||
item.kind === "assistant_message" &&
|
||||
canonicalTailAssistant?.kind === "assistant_message" &&
|
||||
item.text.startsWith(canonicalTailAssistant.text)
|
||||
);
|
||||
}
|
||||
return (
|
||||
input.preserveContinuity ||
|
||||
(item.kind === "user_message" &&
|
||||
item.clientMessageId !== undefined &&
|
||||
input.sendingClientMessageIds.has(item.clientMessageId))
|
||||
);
|
||||
}
|
||||
|
||||
function preserveReplacementHead(
|
||||
canonical: readonly StreamItem[],
|
||||
tail: StreamItem[],
|
||||
currentHead: StreamItem[],
|
||||
preserveContinuity: boolean,
|
||||
sendingClientMessageIds: ReadonlySet<string>,
|
||||
canonicalCoverage: 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,
|
||||
@@ -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 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(local)
|
||||
: local.clientMessageId !== undefined && sendingClientMessageIds.has(local.clientMessageId);
|
||||
? isUnreconciledLocalUserMessage(item)
|
||||
: sendingClientMessageIds.has(item.clientMessageId);
|
||||
if (preserveLocal) {
|
||||
nextTail.push(local);
|
||||
} else if (
|
||||
input.preserveContinuity &&
|
||||
local.timelineCursor &&
|
||||
isAfterCanonicalCoverage(local.timelineCursor, input.canonicalCoverage)
|
||||
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<UserMessageItem, "timelineCursor" | "source"> {
|
||||
return cursor ? { timelineCursor: anchorOf(cursor), source: sourceFor(cursor) } : {};
|
||||
}
|
||||
|
||||
export function rowStartSeq(item: StreamItem): number | undefined {
|
||||
return item.source?.startSeq ?? item.timelineCursor?.seq;
|
||||
}
|
||||
|
||||
function rowChunks(row: TextRow): SourceChunk[] {
|
||||
if (row.source?.chunks) return row.source.chunks;
|
||||
return row.timelineCursor ? [{ seq: row.timelineCursor.seq, offset: 0 }] : [];
|
||||
}
|
||||
|
||||
function chunkTexts(row: TextRow): Array<{ seq: number; text: string }> {
|
||||
const chunks = rowChunks(row);
|
||||
return chunks.map((chunk, index) => ({
|
||||
seq: chunk.seq,
|
||||
text: row.text.slice(chunk.offset, chunks[index + 1]?.offset ?? row.text.length),
|
||||
}));
|
||||
}
|
||||
|
||||
function chunksOf(parts: ReadonlyArray<{ seq: number; text: string }>): SourceChunk[] {
|
||||
let offset = 0;
|
||||
return parts.map((part) => {
|
||||
const chunk = { seq: part.seq, offset };
|
||||
offset += part.text.length;
|
||||
return chunk;
|
||||
});
|
||||
}
|
||||
|
||||
function sliceChunks(chunks: readonly SourceChunk[], start: number, end: number): SourceChunk[] {
|
||||
const inside = chunks
|
||||
.filter((chunk) => chunk.offset >= start && chunk.offset < end)
|
||||
.map((chunk) => ({ seq: chunk.seq, offset: chunk.offset - start }));
|
||||
const spanning = chunks.findLast((chunk) => chunk.offset < start);
|
||||
if (spanning && inside[0]?.offset !== 0) inside.unshift({ seq: spanning.seq, offset: 0 });
|
||||
return inside;
|
||||
}
|
||||
|
||||
export interface CanonicalTextUnit {
|
||||
text: string;
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// A canonical text unit owns the source positions from `startSeq` through `endSeq`. Among the rows
|
||||
// that belong to the same message it replaces exactly the chunks inside that coverage, keeps chunks
|
||||
// before it and after it, and lands where the covered chunks were, so a page that lags the live
|
||||
// stream restores the prefix it certifies without touching text the stream delivered later. Rows
|
||||
// keep their identity and split; rows left with no text are dropped.
|
||||
function coverageOf(seq: number, unit: CanonicalTextUnit): "before" | "covered" | "after" {
|
||||
if (seq < unit.startSeq) return "before";
|
||||
return seq > unit.endSeq ? "after" : "covered";
|
||||
}
|
||||
|
||||
export function mergeCanonicalText<Row extends TextRow>(
|
||||
owned: readonly Row[],
|
||||
unit: CanonicalTextUnit,
|
||||
): Row[] {
|
||||
const parts = owned.map((row) => {
|
||||
const positioned = row.timelineCursor?.epoch === unit.epoch;
|
||||
return chunkTexts(row).map((part, index) => ({
|
||||
seq: part.seq,
|
||||
startSeq: index === 0 ? (rowStartSeq(row) ?? part.seq) : part.seq,
|
||||
text: part.text,
|
||||
where: positioned ? coverageOf(part.seq, unit) : ("covered" as const),
|
||||
}));
|
||||
});
|
||||
let anchor = parts.findIndex((rowParts) => rowParts.some((part) => part.where !== "before"));
|
||||
if (anchor < 0) anchor = owned.length - 1;
|
||||
const result: Row[] = [];
|
||||
for (const [index, row] of owned.entries()) {
|
||||
const rowParts = parts[index] ?? [];
|
||||
if (index < anchor) {
|
||||
result.push(row);
|
||||
continue;
|
||||
}
|
||||
const before = index === anchor ? rowParts.filter((part) => part.where === "before") : [];
|
||||
const after = rowParts.filter((part) => part.where === "after");
|
||||
const kept =
|
||||
index === anchor
|
||||
? [
|
||||
...before,
|
||||
{
|
||||
seq: unit.endSeq,
|
||||
startSeq: unit.startSeq,
|
||||
text: unit.text,
|
||||
where: "covered" as const,
|
||||
},
|
||||
...after,
|
||||
]
|
||||
: after;
|
||||
if (kept.length === 0) continue;
|
||||
const text = kept.map((part) => part.text).join("");
|
||||
const last = kept.at(-1);
|
||||
const first = kept[0];
|
||||
result.push({
|
||||
...row,
|
||||
text,
|
||||
timestamp: index === anchor && after.length === 0 ? unit.timestamp : row.timestamp,
|
||||
timelineCursor: { epoch: unit.epoch, seq: last?.seq ?? unit.endSeq },
|
||||
source: {
|
||||
startSeq: first?.startSeq ?? unit.startSeq,
|
||||
chunks: chunksOf(kept),
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Appends one text row's content to another, keeping every chunk position.
|
||||
export function joinTextRows<Row extends TextRow>(first: Row, second: Row): Row {
|
||||
const parts = [...chunkTexts(first), ...chunkTexts(second)];
|
||||
return {
|
||||
...second,
|
||||
text: `${first.text}${second.text}`,
|
||||
...(second.timelineCursor
|
||||
? {
|
||||
source: {
|
||||
startSeq: rowStartSeq(first) ?? second.timelineCursor.seq,
|
||||
chunks: chunksOf(parts),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export type OrchestratorToolCallStatus = "executing" | "completed" | "failed";
|
||||
@@ -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<string>;
|
||||
timelineCursor?: TimelinePosition;
|
||||
timelineCursor?: SourcedPosition;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -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<string>,
|
||||
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<TextRow, "timelineCursor" | "source"> {
|
||||
if (!timelineCursor) return {};
|
||||
return {
|
||||
timelineCursor: anchorOf(timelineCursor),
|
||||
source: {
|
||||
startSeq: timelineCursor.startSeq ?? timelineCursor.seq,
|
||||
chunks: [{ seq: timelineCursor.seq, offset: 0 }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extendTextRow<Row extends TextRow>(
|
||||
row: Row,
|
||||
chunk: string,
|
||||
timestamp: Date,
|
||||
timelineCursor: SourcedPosition | undefined,
|
||||
): Row {
|
||||
return {
|
||||
...row,
|
||||
text: `${row.text}${chunk}`,
|
||||
timestamp,
|
||||
...(timelineCursor
|
||||
? {
|
||||
timelineCursor: anchorOf(timelineCursor),
|
||||
source: {
|
||||
startSeq: sourceFor(timelineCursor, row).startSeq,
|
||||
chunks: [...rowChunks(row), { seq: timelineCursor.seq, offset: row.text.length }],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function appendThought(
|
||||
state: StreamItem[],
|
||||
text: string,
|
||||
timestamp: Date,
|
||||
timelineCursor?: 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<AgentTimelineItem, { type: "plugin" }>,
|
||||
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<string>,
|
||||
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<StreamUpdateOptions, "source" | "reservedItemIds">,
|
||||
): 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<AssistantMessageItem>((block, offset) => ({
|
||||
// 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: firstBlockIndex + offset,
|
||||
}),
|
||||
id: createAssistantBlockId({ groupId: blockGroupId, blockIndex }),
|
||||
blockGroupId,
|
||||
blockIndex: firstBlockIndex + offset,
|
||||
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;
|
||||
|
||||
@@ -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,9 +119,9 @@ describe("message submission authority", () => {
|
||||
const agentId = "agent-1";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const clientMessageId = beginPendingSubmission(serverId, agentId);
|
||||
store.setAgentStreamState(serverId, agentId, {
|
||||
tail: [
|
||||
const { clientMessageId, replica } = beginPendingSubmission(serverId, agentId);
|
||||
replica.handoffSubmission(
|
||||
agentId,
|
||||
createUserMessage({
|
||||
id: "provider-message",
|
||||
messageId: "provider-message",
|
||||
@@ -118,18 +129,34 @@ describe("message submission authority", () => {
|
||||
text: "Run this",
|
||||
timestamp: new Date("2026-07-27T10:00:01.000Z"),
|
||||
}),
|
||||
],
|
||||
head: [],
|
||||
});
|
||||
);
|
||||
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0]
|
||||
?.providerAcknowledged,
|
||||
).toBe(false);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user