mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
* Revert "Restore cached conversations before reconnecting (#4421)"
This reverts commit 1c51b52d07.
* fix(app): restore provider loading on native
* fix(app): preserve native navigators across appearance changes
Reapply the isolated appearance boundaries and native menu transform origin from #4421 so appearance hydration preserves native navigator and gesture host lifetimes.
* fix(app): retain cached baselines during live updates
Read the timeline concurrently with agent preparation and reconcile display-only cache rows beneath the live head using the existing reducer. Merge cached directory entries beneath live state while preserving the existing checkpoint race guard.
This commit is contained in:
+15
-8
@@ -113,14 +113,21 @@ Cross-platform React Native app that connects to one or more daemons.
|
||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
Consumers request data from host-lived owners without choosing memory, disk or network. Cached rows
|
||||
satisfy the same readiness projection as network rows, including offline. Directory restoration
|
||||
merges under live/local changes; network refresh waits for that baseline. Only completed catch-up
|
||||
advances the directory checkpoint because a live sequence can follow an offline gap. The owner
|
||||
retains surface demand across reconnects; React does not track connection generations. Storage
|
||||
commit and scoped-read guarantees belong to [data-model.md](data-model.md#replica-row-store);
|
||||
timeline display, resume and lifetime contracts belong to
|
||||
[timeline-sync.md](timeline-sync.md#client-replica-lifetime).
|
||||
Consumers request directory or timeline data without choosing memory, cache, or network. The owner
|
||||
publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an
|
||||
invalid row uses that same path. Offline demand still publishes an accepted cache hit and defers
|
||||
network reconciliation. Cache loading is demand-driven: opening a chat reads its agent row and
|
||||
focused timeline row, plus the workspace and project rows needed by the route; opening a directory
|
||||
reads directory rows for that host and establishes its live subscription when connected. Accepted
|
||||
cached rows satisfy the same consumer-readiness projection as network rows. Host registry startup and
|
||||
host connection do not create directory demand or install replicas. The directory owner retains
|
||||
declared surface demand and re-establishes network reconciliation after reconnect; React does not
|
||||
track connection generations. Late cache reads cannot replace state already advanced by live or
|
||||
authoritative network data. Owners explicitly persist accepted commits; directory rows and their
|
||||
checkpoint share one storage transaction. See
|
||||
[data-model.md](data-model.md#replica-row-store)
|
||||
for the storage shape and [timeline-sync.md](timeline-sync.md#client-replica-lifetime) for timeline
|
||||
resume behavior.
|
||||
|
||||
The three directory entity types have independent monotonic sequences and share one daemon
|
||||
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
|
||||
|
||||
+7
-11
@@ -578,20 +578,16 @@ compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`,
|
||||
uses the singleton id.
|
||||
|
||||
The store is a typed persistence boundary. It returns values to directory and timeline owners and
|
||||
accepts their explicit commits; it never reads or writes UI state.
|
||||
One invalid row is deleted and returned as a miss without affecting other rows.
|
||||
accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested
|
||||
host, kinds, and ids. Opening a cached workspace uses exact workspace and project keys rather than a
|
||||
directory scan. One invalid row is deleted and returned as a miss without affecting other rows.
|
||||
An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a
|
||||
later launch cannot accept a checkpoint for a partial baseline. Directory changes and their
|
||||
checkpoint are also applied in one transaction. Cold reads query only the requested host's directory
|
||||
or agent timeline. Accepted commits overlay those reads, including changes accepted or persisted
|
||||
while a read was in flight. Host removal invalidates old reads; reconciliation exposes moved rows
|
||||
before the disk rename finishes. Disk is write-behind: a change stays pending until the disk
|
||||
confirms it, so failed writes preserve accepted values. Timeline eligibility is checked without
|
||||
serialization; the coalesced write encodes the latest accepted page once.
|
||||
checkpoint are also applied in one transaction.
|
||||
|
||||
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. The first
|
||||
write loads the global row index and accounts for its bytes; requested reads do not await that
|
||||
work. Later writes update the index. The row store is not encrypted. A cached timeline can contain
|
||||
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget
|
||||
bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or
|
||||
before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain
|
||||
source code, prompts, and tool output; encrypted-at-rest storage is a separate security decision.
|
||||
|
||||
### Draft Store
|
||||
|
||||
+38
-72
@@ -36,24 +36,11 @@ Large unbounded timeline responses can exceed relay frame limits, so catch-up us
|
||||
|
||||
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
|
||||
|
||||
When live delivery detects a sequence gap, the app fetches `direction: "after"` from its current
|
||||
cursor and remembers the sequence the live row reached. If the daemon responds with
|
||||
`hasNewer: true`, the app immediately fetches the next page from `endCursor`. A catch-up is
|
||||
complete only when the daemon reports `hasNewer: false` and the accepted coverage has reached
|
||||
every sequence the live stream reported while the catch-up ran: a page the daemon built before
|
||||
those rows existed cannot settle them, however it was requested, so the owner continues from the
|
||||
cursor that page established. One catch-up runs per agent; a gap observed while it is running
|
||||
raises its obligation instead of starting another request. A non-advancing reply cannot disprove
|
||||
activity observed while the request was in flight. A newly raised obligation starts the next
|
||||
page immediately, without an error. A reply that makes no progress against an obligation already
|
||||
known when it started retains that obligation and uses bounded error backoff. Cancellation (the agent leaves view,
|
||||
the connection drops) forgets the observed head; the next catch-up starts from the cursor and the
|
||||
daemon returns everything after it.
|
||||
When live delivery detects a sequence gap, the app fetches `direction: "after"`. If the daemon
|
||||
responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap
|
||||
recovery is complete only when `hasNewer: false`.
|
||||
|
||||
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
|
||||
Response completion is independent of row admission: an obsolete tail can finish its initialization
|
||||
without replacing newer accepted rows. Requests carry the existing init-promise identity, so an old
|
||||
response cannot settle a replacement initialization.
|
||||
|
||||
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
|
||||
user-driven by scrolling upward.
|
||||
@@ -95,9 +82,7 @@ authoritative range first.
|
||||
that cursor are applied. Already-covered rows are not replayed.
|
||||
- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the
|
||||
latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved
|
||||
local submissions; it never retains two discontiguous canonical ranges. A tail whose window ends
|
||||
behind the local cursor is a rewind, so it supersedes every positioned row up to that cursor
|
||||
rather than only the positions it carries; a gap replacement covers only what it carries.
|
||||
local submissions; it never retains two discontiguous canonical ranges.
|
||||
|
||||
The installed tail carries `hasOlder`, so history skipped by a replacement remains reachable through
|
||||
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
|
||||
@@ -105,51 +90,39 @@ history start; a response requested from a pre-replacement range is stale and is
|
||||
|
||||
## Client replica lifetime
|
||||
|
||||
`timeline/replica.ts` is the host-lived timeline write authority. Cache restore, pages, live batches,
|
||||
submissions and removal publish atomically into the existing Zustand read projection; unchanged
|
||||
facts retain their references. `viewed-timeline-sync.ts` owns demand and fetch scheduling, available
|
||||
before a network client so saved history can paint offline. Matching display and verified inputs
|
||||
share one reduction. Fully covered identified text adopts canonical row identity and metadata;
|
||||
partial coverage preserves live rows and newer text. Persistence uses the accepted transition,
|
||||
never mutable UI state.
|
||||
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
|
||||
owner wraps cached preparation, network catch-up, accepted timeline application, and persistence
|
||||
behind one interface. React supplies transport and projection operations without selecting a cache
|
||||
path or issuing a separate persistence notification.
|
||||
|
||||
Unpositioned live updates do not revoke known in-session source coverage or sequencing: current
|
||||
daemons also emit out-of-band timeline updates. They can make the restart page display-only, which
|
||||
grants no resume coverage when reopened. Display pagination remains available, and older pages
|
||||
cannot certify unpositioned content. Positioned activity ahead of incomplete catch-up preserves the
|
||||
preceding certified baseline. A cold overflow awaiting its latest tail still accepts live observations;
|
||||
only an intentionally detached, synchronized history window excludes them.
|
||||
Epoch changes invalidate old display positions; authoritative resets, including empty windows,
|
||||
invalidate the old durable baseline. A delayed old-epoch cache cannot replace newer live display.
|
||||
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||
session and host-scoped setup state together.
|
||||
|
||||
The restart window holds at most the latest40 complete entries in source-start order. Loading older
|
||||
scrollback cannot populate or enlarge it, even when projection merging leaves a short page or its
|
||||
certificate has been withdrawn. Retained messages can gain complete continuations; overlapping
|
||||
older tool lifetimes do not pull earlier entries into the window. `startSeq` identifies the source-start
|
||||
boundary for `before` pagination; `endSeq` is the independently established synchronization position
|
||||
for `after`. A discarded older tool may finish last, so the latter can exceed every retained row's
|
||||
completion position. `hasOlder` preserves access to omitted history.
|
||||
The timeline owner asks durable replica storage for an agent when that agent becomes visible. An
|
||||
accepted row paints immediately before subscription acknowledgement or timeline fetch. The stored
|
||||
range describes those exact items: `startSeq` drives older pagination and `endSeq` drives forward
|
||||
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
|
||||
history. Code outside the owner does not distinguish cached and network timelines.
|
||||
|
||||
Cache values without source provenance paint verbatim but cannot authorize resume, including after
|
||||
new live activity. Canonical replacement restores certification through the ordinary bounded tail
|
||||
bootstrap; no local sorting or text deduplication repairs saved rows. Disjoint retained ranges keep
|
||||
the preceding certified baseline. The codec preserves complete text, source chunks and attachment
|
||||
references; unreconciled local submissions and UI sync generations are not durable. Cache budgeting
|
||||
and write-behind guarantees belong to [data-model.md](data-model.md#replica-row-store).
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
|
||||
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
||||
|
||||
Preparation keeps one promise per agent lifetime. Host identity transfer immediately republishes
|
||||
retained state; a pending read repeats only when identity changed during that read, because physical
|
||||
rename may have retired its key. Settled transfers perform no storage read. Directory scope exclusion
|
||||
ends the lifetime and rejects delayed work but retains the saved timeline; only explicit deletion
|
||||
removes it. First admission preserves in-flight work; re-admission begins a fresh lifetime. Retained
|
||||
timeline data alone does not provide offline archived-route metadata.
|
||||
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
||||
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
||||
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
|
||||
falsely certifies discarded source rows. A display-only row paints without granting synchronization
|
||||
authority, so the owner uses the ordinary bounded `tail` bootstrap.
|
||||
|
||||
DirectorySync coalesces fetch and acceptance into one operation. The first resume is bounded: a reply
|
||||
with more history leads to one latest tail, while live gap recovery still pages to completion. A tail
|
||||
passed by activity accepted during its request is obsolete; a tail behind the request's starting
|
||||
cursor demonstrates rewind. Client window-replacement intent remains separate from daemon reset.
|
||||
Covered canonical positions replace matching display history, while positioned rows beyond the page
|
||||
survive as an unverified overlay. Submission continuity follows the rules below.
|
||||
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
|
||||
the authoritative range through the existing forward-page path. The cache does not persist sync
|
||||
generation or unreconciled local submissions.
|
||||
|
||||
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
|
||||
replacement keeps only positioned rows newer than the page it installs, while unresolved local
|
||||
user presentations identify themselves by having client identity without a timeline position. This
|
||||
prevents a page from duplicating rows it already covers without coupling display continuity to the
|
||||
shorter-lived submission registry. Unreconciled local presentations are not persisted in the durable
|
||||
replica cache.
|
||||
|
||||
## Selective and legacy delivery
|
||||
|
||||
@@ -171,18 +144,12 @@ version.
|
||||
|
||||
## Projected pages reconcile with live presentation
|
||||
|
||||
A projected page is canonical state, not live deltas. Positioned rows carry their source start and
|
||||
text chunk offsets so page reconciliation replaces covered text while retaining newer chunks.
|
||||
Tools keep their first-appearance order even when their completion arrives later. Both display lanes
|
||||
share this source order; a missing canonical unit lands at its source start.
|
||||
|
||||
Message identity alone does not identify a segment: tools can divide same-ID text, and markdown can
|
||||
split one segment into several rows. For unpositioned text, reconcile only a uniquely matching
|
||||
contiguous segment using the same markdown boundaries as live rendering. A lagging canonical prefix
|
||||
leaves newer text unpositioned. Known tool positions constrain ownership; identical unpositioned
|
||||
segments without a distinguishing position remain ambiguous and are preserved. Text alone never
|
||||
establishes message identity. Anonymous rows retain the conservative cursor-straddle fallback.
|
||||
Insertion must not merge an identified segment whose ownership reconciliation already declined.
|
||||
A projected page is canonical state, not a sequence of live deltas. One projected item can overlap
|
||||
rows already received live—for example, a tool call retained at its original display position while
|
||||
its completion advances `seqEnd`, followed by a merged assistant message. The app uses
|
||||
`sourceSeqRanges` to replace overlapping assistant and reasoning projections before applying the
|
||||
remaining page through the existing stream reducer. It must not append full projected text to a
|
||||
live prefix.
|
||||
|
||||
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
|
||||
send-now, and the host runtime's automatic queue drain — goes through
|
||||
@@ -263,7 +230,6 @@ canonical assistant prefix, it stays in the head lane. No row may be returned in
|
||||
|
||||
- Server live stream forwarding: `packages/server/src/server/session.ts`
|
||||
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
|
||||
- App timeline write authority: `packages/app/src/timeline/replica.ts`
|
||||
- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts`
|
||||
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
|
||||
- Session wiring: `packages/app/src/contexts/session-context.tsx`
|
||||
|
||||
@@ -37,7 +37,7 @@ import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-
|
||||
import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app";
|
||||
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
||||
import {
|
||||
expectEmptyBaselineBootstrapsWithOneTail,
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
import {
|
||||
@@ -484,8 +484,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
await expect(promptRow).toBeVisible();
|
||||
await expect(response).toBeVisible();
|
||||
await expectRenderedBefore(promptRow, response);
|
||||
// The initial timeline was empty and live rows were suppressed: there is no resume cursor.
|
||||
expectEmptyBaselineBootstrapsWithOneTail(gate, requestsBeforeReturn);
|
||||
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
|
||||
} finally {
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
gate.restore();
|
||||
|
||||
@@ -94,18 +94,14 @@ interface PopoverFrameRecorderWindow extends Window {
|
||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||
}
|
||||
|
||||
async function expectAutocompleteCoveredAtPoint(page: Page, x: number, y: number): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
([pointX, pointY]) => {
|
||||
const element = document.elementFromPoint(pointX, pointY);
|
||||
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
|
||||
},
|
||||
[x, y],
|
||||
),
|
||||
)
|
||||
.not.toBe("composer-autocomplete-popover");
|
||||
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
|
||||
return page.evaluate(
|
||||
([pointX, pointY]) => {
|
||||
const element = document.elementFromPoint(pointX, pointY);
|
||||
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
}
|
||||
|
||||
async function installListCommandsStub(page: Page): Promise<void> {
|
||||
@@ -680,11 +676,13 @@ test.describe("Composer autocomplete", () => {
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
await expectAutocompleteCoveredAtPoint(
|
||||
const topTestId = await getTopTestIdAtPoint(
|
||||
page,
|
||||
popoverBox!.x + popoverBox!.width / 2,
|
||||
popoverBox!.y + popoverBox!.height / 2,
|
||||
);
|
||||
|
||||
expect(topTestId).not.toBe("composer-autocomplete-popover");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
@@ -30,9 +30,8 @@ const PROMPT_PROFILE: TerminalProfile = {
|
||||
args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
async function expectChatLaunchComplete(page: Page): Promise<void> {
|
||||
const agentTab = page.getByTestId(/^workspace-tab-agent_/).filter({ visible: true });
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
function hasLeftNewWorkspaceRoute(url: URL): boolean {
|
||||
return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`);
|
||||
}
|
||||
|
||||
test.describe("New workspace: launch target memory", () => {
|
||||
@@ -79,8 +78,10 @@ test.describe("New workspace: launch target memory", () => {
|
||||
await selectChatLaunch(page);
|
||||
await expectChatLaunchSelected(page);
|
||||
await submitNewWorkspacePrompt(page, "hello from chat");
|
||||
// Leaving /new can expose a previous workspace before the submitted draft opens.
|
||||
await expectChatLaunchComplete(page);
|
||||
// The chat handoff navigates asynchronously (draft -> created agent). Let
|
||||
// it fully settle before moving on, so it can't fire a late redirect
|
||||
// that races the next step's navigation.
|
||||
await page.waitForURL(hasLeftNewWorkspaceRoute, { timeout: 30_000 });
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectChatLaunchSelected(page);
|
||||
|
||||
@@ -96,9 +96,8 @@ test.describe("Model B sidebar shape", () => {
|
||||
try {
|
||||
// Open the workspace and materialize both an agent tab and a terminal tab.
|
||||
await gotoWorkspace(page, mock.workspaceId);
|
||||
await expect
|
||||
.poll(() => getVisibleWorkspaceAgentTabIds(page))
|
||||
.toContain(`workspace-tab-agent_${mock.agentId}`);
|
||||
const agentTabs = await getVisibleWorkspaceAgentTabIds(page);
|
||||
expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`);
|
||||
|
||||
await clickNewTerminal(page);
|
||||
await expect(
|
||||
|
||||
@@ -494,19 +494,18 @@ export async function holdAgentOlderTimelinePages(
|
||||
};
|
||||
}
|
||||
|
||||
export async function delayAgentBootstrapTimelineResponse(
|
||||
export async function delayAgentBootstrapTailResponse(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<BootstrapTimelineGate> {
|
||||
let bootstrapReleased = false;
|
||||
let bootstrapResponseSeen = false;
|
||||
let tailReleased = false;
|
||||
let catchUpReleased = false;
|
||||
const delayedBootstrapForwards: Array<() => void> = [];
|
||||
const delayedTailForwards: Array<() => void> = [];
|
||||
const delayedCatchUpForwards: Array<() => void> = [];
|
||||
let resolveDelayedBootstrap: (() => void) | null = null;
|
||||
let resolveDelayedTail: (() => void) | null = null;
|
||||
let resolveDelayedCatchUp: (() => void) | null = null;
|
||||
const delayedBootstrap = new Promise<void>((resolve) => {
|
||||
resolveDelayedBootstrap = resolve;
|
||||
const delayedTail = new Promise<void>((resolve) => {
|
||||
resolveDelayedTail = resolve;
|
||||
});
|
||||
const delayedCatchUp = new Promise<void>((resolve) => {
|
||||
resolveDelayedCatchUp = resolve;
|
||||
@@ -520,16 +519,10 @@ export async function delayAgentBootstrapTimelineResponse(
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
const isTimelineResponse =
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId;
|
||||
// A certified replica bootstraps with after; an empty/display-only replica uses tail.
|
||||
if (
|
||||
isTimelineResponse &&
|
||||
!bootstrapResponseSeen &&
|
||||
(payload.direction === "tail" || payload.direction === "after")
|
||||
) {
|
||||
bootstrapResponseSeen = true;
|
||||
resolveDelayedBootstrap?.();
|
||||
if (bootstrapReleased) ws.send(message);
|
||||
else delayedBootstrapForwards.push(() => ws.send(message));
|
||||
if (isTimelineResponse && payload.direction === "tail") {
|
||||
resolveDelayedTail?.();
|
||||
if (tailReleased) ws.send(message);
|
||||
else delayedTailForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
if (isTimelineResponse && payload.direction === "after") {
|
||||
@@ -544,14 +537,14 @@ export async function delayAgentBootstrapTimelineResponse(
|
||||
|
||||
return {
|
||||
release() {
|
||||
bootstrapReleased = true;
|
||||
for (const forward of delayedBootstrapForwards.splice(0)) forward();
|
||||
tailReleased = true;
|
||||
for (const forward of delayedTailForwards.splice(0)) forward();
|
||||
},
|
||||
releaseCatchUp() {
|
||||
catchUpReleased = true;
|
||||
for (const forward of delayedCatchUpForwards.splice(0)) forward();
|
||||
},
|
||||
waitForDelayedResponse: () => delayedBootstrap,
|
||||
waitForDelayedResponse: () => delayedTail,
|
||||
waitForDelayedCatchUp: () => delayedCatchUp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { readReplicaCache } from "./replica-cache-storage";
|
||||
import {
|
||||
delayAgentBootstrapTimelineResponse,
|
||||
delayAgentBootstrapTailResponse,
|
||||
delayAgentOlderTimelineResponse,
|
||||
holdDaemonHydration,
|
||||
holdAgentOlderTimelinePages,
|
||||
@@ -296,10 +296,9 @@ export async function reloadAgentTimelineFromPersistedReplica(
|
||||
const timeline = cache?.hosts
|
||||
?.flatMap((host) => host.timelines)
|
||||
.find((candidate) => candidate.agentId === agent.agentId);
|
||||
// This long fixture fills the latest 40-entry restart page, even after loading scrollback.
|
||||
return timeline?.items?.length;
|
||||
return timeline?.items?.length === 50;
|
||||
})
|
||||
.toBe(40);
|
||||
.toBe(true);
|
||||
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
@@ -504,7 +503,7 @@ export async function holdBootstrapTimelinePage(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<BootstrapTimelineGate> {
|
||||
return delayAgentBootstrapTimelineResponse(page, agent.agentId);
|
||||
return delayAgentBootstrapTailResponse(page, agent.agentId);
|
||||
}
|
||||
|
||||
export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise<string> {
|
||||
|
||||
@@ -48,14 +48,6 @@ export function expectOneResumeCheckWithoutTail(
|
||||
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(0);
|
||||
}
|
||||
|
||||
export function expectEmptyBaselineBootstrapsWithOneTail(
|
||||
gate: DaemonWebSocketGate,
|
||||
before: TimelineRequestCounts,
|
||||
): void {
|
||||
expect(gate.getTimelineRequestCount("after") - before.after).toBe(0);
|
||||
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1);
|
||||
}
|
||||
|
||||
export function expectResumeOverflowFallsBackToOneTail(
|
||||
gate: DaemonWebSocketGate,
|
||||
before: TimelineRequestCounts,
|
||||
|
||||
@@ -38,6 +38,12 @@ function HostAgentReadyRouteContent() {
|
||||
}
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!serverId || !agentId || agentWorkspaceId) return;
|
||||
void getHostRuntimeStore()
|
||||
.prepareAgentRoute(serverId, agentId)
|
||||
.catch(() => undefined);
|
||||
}, [agentId, agentWorkspaceId, serverId]);
|
||||
const shouldLookupAgent = Boolean(
|
||||
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
||||
);
|
||||
|
||||
@@ -2,34 +2,17 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "./create-flow";
|
||||
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
|
||||
describe("useDraftAgentCreateFlow", () => {
|
||||
beforeEach(() => {
|
||||
useCreateFlowStore.setState({ pendingByDraftId: {} });
|
||||
getHostRuntimeStore().syncHosts([
|
||||
{
|
||||
serverId: "server-1",
|
||||
label: "test",
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
connections: [],
|
||||
preferredConnectionId: null,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => getHostRuntimeStore().syncHosts([]));
|
||||
|
||||
it("renders a prepared new-workspace submission before continuing it", async () => {
|
||||
const image: UserMessageImageAttachment = {
|
||||
id: "image-1",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
splitComposerAttachmentsForSubmit,
|
||||
} from "@/composer/attachments/submit";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
createUserMessage,
|
||||
@@ -214,18 +214,17 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
|
||||
if (createResult.agentId) {
|
||||
updatePendingAgentId({ draftId, agentId: createResult.agentId });
|
||||
getHostRuntimeStore()
|
||||
.getTimelineReplica(pendingServerId)
|
||||
.handoffSubmission(
|
||||
createResult.agentId,
|
||||
createUserMessage({
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
text: attempt.text,
|
||||
timestamp: attempt.timestamp,
|
||||
images: attempt.images,
|
||||
attachments: attempt.attachments,
|
||||
}),
|
||||
);
|
||||
handoffCreatedAgentMessageSubmission(
|
||||
pendingServerId,
|
||||
createResult.agentId,
|
||||
createUserMessage({
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
text: attempt.text,
|
||||
timestamp: attempt.timestamp,
|
||||
images: attempt.images,
|
||||
attachments: attempt.attachments,
|
||||
}),
|
||||
);
|
||||
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
|
||||
}
|
||||
|
||||
|
||||
@@ -1484,10 +1484,7 @@ function ComposerContentImpl({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
}),
|
||||
encodeImages,
|
||||
submission: createMessageSubmissionWriter(
|
||||
serverId,
|
||||
getHostRuntimeStore().getTimelineReplica(serverId),
|
||||
),
|
||||
submission: createMessageSubmissionWriter(serverId),
|
||||
activeTurnBehavior,
|
||||
activeTurnId:
|
||||
activeTurnBehavior === "steer"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createTimelineReplica } from "@/timeline/replica";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -41,17 +40,10 @@ afterEach(() => {
|
||||
test("tracks submissions when canonical prompts are supported", () => {
|
||||
initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: true });
|
||||
|
||||
createMessageSubmissionWriter(
|
||||
CANONICAL_ONLY_SERVER_ID,
|
||||
createTimelineReplica({
|
||||
serverId: CANONICAL_ONLY_SERVER_ID,
|
||||
storage: {
|
||||
removeTimeline: () => undefined,
|
||||
readTimeline: async () => undefined,
|
||||
commitTimeline: () => undefined,
|
||||
},
|
||||
}),
|
||||
).begin("agent-1", submittedMessage("canonical-only-message"));
|
||||
createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID).begin(
|
||||
"agent-1",
|
||||
submittedMessage("canonical-only-message"),
|
||||
);
|
||||
|
||||
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]);
|
||||
});
|
||||
|
||||
@@ -1,19 +1,72 @@
|
||||
import type { MessageSubmissionWriter } from "@/composer/actions";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { TimelineReplica } from "@/timeline/replica";
|
||||
import {
|
||||
appendSubmittedUserMessage,
|
||||
removeSubmittedUserMessage,
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
|
||||
export function createMessageSubmissionWriter(
|
||||
function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (!session) return;
|
||||
const tail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const head = session.agentStreamHead.get(agentId) ?? [];
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message }));
|
||||
}
|
||||
|
||||
function removeUntrackedSubmission(
|
||||
serverId: string,
|
||||
replica: TimelineReplica,
|
||||
): MessageSubmissionWriter {
|
||||
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6.
|
||||
const tracked =
|
||||
useSessionStore.getState().sessions[serverId]?.serverInfo?.features
|
||||
?.canonicalSubmittedPrompts === true;
|
||||
agentId: string,
|
||||
clientMessageId: string,
|
||||
): void {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (!session) return;
|
||||
const tail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const head = session.agentStreamHead.get(agentId) ?? [];
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentStreamState(
|
||||
serverId,
|
||||
agentId,
|
||||
removeSubmittedUserMessage({ tail, head, clientMessageId }),
|
||||
);
|
||||
}
|
||||
|
||||
function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
|
||||
return {
|
||||
begin: (agentId, message) => replica.beginSubmission(agentId, message, tracked),
|
||||
accept: (agentId, clientMessageId) => replica.acceptSubmission(agentId, clientMessageId),
|
||||
reject: (agentId, clientMessageId) =>
|
||||
replica.rejectSubmission(agentId, clientMessageId, tracked),
|
||||
begin: (agentId, message) => appendUntrackedSubmission(serverId, agentId, message),
|
||||
accept: () => undefined,
|
||||
reject: (agentId, clientMessageId) => {
|
||||
removeUntrackedSubmission(serverId, agentId, clientMessageId);
|
||||
return "rejected";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
|
||||
const supportsTrackedMessageSubmissions =
|
||||
useSessionStore.getState().sessions[serverId]?.serverInfo?.features
|
||||
?.canonicalSubmittedPrompts === true;
|
||||
if (!supportsTrackedMessageSubmissions) {
|
||||
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6.
|
||||
return createUntrackedMessageSubmissionWriter(serverId);
|
||||
}
|
||||
return {
|
||||
begin: (agentId, message) =>
|
||||
useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message),
|
||||
accept: (agentId, clientMessageId) =>
|
||||
useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId),
|
||||
reject: (agentId, clientMessageId) =>
|
||||
useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
|
||||
};
|
||||
}
|
||||
|
||||
export function handoffCreatedAgentMessageSubmission(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
message: UserMessageItem,
|
||||
): boolean {
|
||||
return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
|
||||
import {
|
||||
consumeForcedTimelineTailReplacement,
|
||||
type TimelineDeliveryMode,
|
||||
type TimelineResponsePayload,
|
||||
type ViewedTimelineOwner,
|
||||
} from "@/timeline/viewed-timeline-sync";
|
||||
import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
@@ -227,12 +229,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
// Zustand store actions
|
||||
const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio);
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
|
||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||
const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
|
||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
||||
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
|
||||
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||
|
||||
// Track focused agent for heartbeat
|
||||
@@ -245,6 +251,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const _sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
||||
const appStateRef = useRef(AppState.currentState);
|
||||
const forcedTimelineTailReplacements = useRef(new Set<string>());
|
||||
const viewedTimelineSyncRef = useRef<ViewedTimelineOwner | null>(null);
|
||||
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
|
||||
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
||||
@@ -410,15 +417,33 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
[serverId, upsertWorkspaceSetupProgress],
|
||||
);
|
||||
|
||||
const applyTimelineResponse = useCallback((receivedPayload: TimelineResponsePayload) => {
|
||||
const payload = consumeForcedTimelineTailReplacement(
|
||||
receivedPayload,
|
||||
forcedTimelineTailReplacements.current,
|
||||
);
|
||||
const owner = viewedTimelineSyncRef.current;
|
||||
if (!owner) throw new Error("Viewed timeline owner is unavailable");
|
||||
owner.applyTimelineResponse(payload);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents);
|
||||
const initialDeliveryMode = getTimelineDeliveryMode(
|
||||
client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline,
|
||||
);
|
||||
const sync = getHostRuntimeStore().getViewedTimelineOwner(serverId);
|
||||
const detach = sync.attachTransport({
|
||||
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
|
||||
initialDeliveryMode,
|
||||
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
|
||||
readCursor: (agentId) => {
|
||||
const timeline = selectAgentTimelineState(
|
||||
useSessionStore.getState().sessions[serverId],
|
||||
agentId,
|
||||
);
|
||||
return timeline.status === "synced" && timeline.range
|
||||
? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq }
|
||||
: undefined;
|
||||
},
|
||||
fetchPage: async (agentId, request) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
@@ -449,8 +474,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
fetchLatestTail: (agentId) =>
|
||||
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch(), true),
|
||||
fetchLatestTail: async (agentId) => {
|
||||
forcedTimelineTailReplacements.current.add(agentId);
|
||||
try {
|
||||
return await getHostRuntimeStore().fetchAgentTimeline(
|
||||
serverId,
|
||||
agentId,
|
||||
planTimelineTailFetch(),
|
||||
);
|
||||
} finally {
|
||||
forcedTimelineTailReplacements.current.delete(agentId);
|
||||
}
|
||||
},
|
||||
reportError: (error) => {
|
||||
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
|
||||
},
|
||||
@@ -460,15 +495,17 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
},
|
||||
});
|
||||
viewedTimelineSyncRef.current = sync;
|
||||
setViewedTimelineSync(serverId, sync);
|
||||
sync.setActive(getIsAppVisible(appStateRef.current));
|
||||
|
||||
return () => {
|
||||
if (viewedTimelineSyncRef.current === sync) {
|
||||
viewedTimelineSyncRef.current = null;
|
||||
}
|
||||
detach();
|
||||
setViewedTimelineSync(serverId, null);
|
||||
sync.dispose();
|
||||
};
|
||||
}, [client, serverId, setInitializingAgents]);
|
||||
}, [client, serverId, setInitializingAgents, setViewedTimelineSync]);
|
||||
|
||||
useEffect(() => {
|
||||
viewedTimelineSyncRef.current?.setConnected(isConnected);
|
||||
@@ -516,6 +553,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
});
|
||||
|
||||
const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => {
|
||||
if (message.type !== "fetch_agent_timeline_response") return;
|
||||
owner.flushStreamAgent(message.payload.agentId);
|
||||
applyTimelineResponse(message.payload);
|
||||
});
|
||||
|
||||
const unsubTimelineReplacement = client.on("agent.timeline.replacement", (message) => {
|
||||
if (message.type !== "agent.timeline.replacement") return;
|
||||
void requestTimelineReplacement(
|
||||
@@ -721,6 +764,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
return () => {
|
||||
unsubTimelineReplacement();
|
||||
unsubAgentStream();
|
||||
unsubAgentTimeline();
|
||||
unsubProviderSubagentUpdate();
|
||||
unsubAgentAttention();
|
||||
unsubCheckoutStatusUpdate();
|
||||
@@ -740,11 +784,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
queryClient,
|
||||
serverId,
|
||||
setIsPlayingAudio,
|
||||
setAgentStreamTail,
|
||||
setAgentStreamHead,
|
||||
clearAgentStreamHead,
|
||||
setInitializingAgents,
|
||||
setAgents,
|
||||
setPendingPermissions,
|
||||
notifyAgentAttention,
|
||||
applyWorkspaceSetupProgress,
|
||||
applyTimelineResponse,
|
||||
updateSessionServerInfo,
|
||||
toast,
|
||||
voiceRuntime,
|
||||
|
||||
@@ -53,15 +53,13 @@ describe("ensureAgentIsInitialized", () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const runtime = new FakeTimelineRuntime();
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
useSessionStore.getState().applyAgentTimelineResponseState(serverId, agentId, {
|
||||
items: [],
|
||||
head: [],
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 42 },
|
||||
older: "none",
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
submissions: [],
|
||||
});
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentTimelineCursor(
|
||||
serverId,
|
||||
new Map([[agentId, { epoch: "epoch-1", startSeq: 1, endSeq: 42 }]]),
|
||||
);
|
||||
useSessionStore.getState().setAgentAuthoritativeHistoryApplied(serverId, agentId, true);
|
||||
|
||||
void ensureAgentIsInitialized({
|
||||
serverId,
|
||||
@@ -131,7 +129,7 @@ describe("ensureAgentIsInitialized", () => {
|
||||
older: "none",
|
||||
newer: false,
|
||||
synchronized: false,
|
||||
submissions: [],
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
void ensureAgentIsInitialized({
|
||||
@@ -174,7 +172,7 @@ describe("ensureAgentIsInitialized", () => {
|
||||
older: "available",
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
submissions: [],
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
void ensureAgentIsInitialized({
|
||||
|
||||
@@ -61,7 +61,6 @@ describe("AgentDirectoryReplica", () => {
|
||||
serverId,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
|
||||
);
|
||||
replica.commitSnapshot([entry(payload("live"))], []);
|
||||
replica.applyTurnLiveness("agent", {
|
||||
@@ -85,7 +84,6 @@ describe("AgentDirectoryReplica", () => {
|
||||
serverId,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
|
||||
);
|
||||
|
||||
expect(replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("network"))).toBe(
|
||||
@@ -106,7 +104,6 @@ describe("AgentDirectoryReplica", () => {
|
||||
serverId,
|
||||
() => undefined,
|
||||
(mutations) => commits.push([...mutations]),
|
||||
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
|
||||
);
|
||||
replica.commitSnapshot([entry(payload("agent"))], []);
|
||||
commits.length = 0;
|
||||
@@ -147,7 +144,6 @@ describe("AgentDirectoryReplica", () => {
|
||||
serverId,
|
||||
(agentId) => stopped.push(agentId),
|
||||
() => undefined,
|
||||
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
|
||||
);
|
||||
replica.commitSnapshot([entry(payload("agent"))], []);
|
||||
replica.applyTurnLiveness("agent", {
|
||||
@@ -170,7 +166,6 @@ describe("AgentDirectoryReplica", () => {
|
||||
serverId,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
|
||||
);
|
||||
replica.commitSnapshot(
|
||||
[
|
||||
@@ -224,7 +219,6 @@ describe("AgentDirectoryReplica", () => {
|
||||
serverId,
|
||||
(agentId) => stopped.push(agentId),
|
||||
() => undefined,
|
||||
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
|
||||
);
|
||||
replica.commitSnapshot(
|
||||
[
|
||||
@@ -268,7 +262,6 @@ describe("AgentDirectoryReplica", () => {
|
||||
serverId,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
|
||||
);
|
||||
replica.commitSnapshot(
|
||||
[
|
||||
@@ -296,16 +289,10 @@ describe("AgentDirectoryReplica", () => {
|
||||
const serverId = "agent-replica";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const timelineLifetimes: string[] = [];
|
||||
const replica = new AgentDirectoryReplica(
|
||||
serverId,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
(agentId) => {
|
||||
timelineLifetimes.push(`remove:${agentId}`);
|
||||
useSessionStore.getState().removeAgentTimeline(serverId, agentId);
|
||||
},
|
||||
(agentId) => timelineLifetimes.push(`accept:${agentId}`),
|
||||
);
|
||||
replica.commitSnapshot([entry(payload("directory"))], []);
|
||||
const directoryPlacement = useSessionStore
|
||||
@@ -332,7 +319,6 @@ describe("AgentDirectoryReplica", () => {
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.projectPlacement,
|
||||
).toEqual(directoryPlacement);
|
||||
expect(timelineLifetimes).toEqual(["accept:agent", "remove:agent", "accept:agent"]);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { type AgentDirectoryDelta, type AgentRemovalReason } from "@/utils/agent-directory-sync";
|
||||
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
||||
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
|
||||
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||
@@ -25,47 +25,52 @@ export interface AgentLifecycleToken {
|
||||
export class AgentDirectoryReplica {
|
||||
private readonly lifecycleVersions = new Map<string, number>();
|
||||
private readonly members = new Set<string>();
|
||||
private readonly pendingCacheReads = new Set<string>();
|
||||
private readonly storeProjection: AgentStoreProjection;
|
||||
|
||||
constructor(
|
||||
private readonly serverId: string,
|
||||
private readonly onStoppedRunning: (agentId: string) => void,
|
||||
private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void,
|
||||
removeTimeline: (agentId: string, reason: AgentRemovalReason) => void,
|
||||
private readonly acceptTimeline: (agentId: string) => void = () => undefined,
|
||||
) {
|
||||
this.storeProjection = new AgentStoreProjection(serverId, removeTimeline);
|
||||
this.storeProjection = new AgentStoreProjection(serverId);
|
||||
}
|
||||
|
||||
captureTimeline(agentId: string): AgentLifecycleToken {
|
||||
return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 };
|
||||
}
|
||||
|
||||
isTimelineCurrent(token: AgentLifecycleToken): boolean {
|
||||
return token.version === (this.lifecycleVersions.get(token.agentId) ?? 0);
|
||||
}
|
||||
|
||||
snapshot(): Map<string, Agent> {
|
||||
return this.storeProjection.snapshot();
|
||||
}
|
||||
|
||||
// Saved rows sit under whatever the live stream already delivered for this connection.
|
||||
captureCache(agentId: string): AgentLifecycleToken {
|
||||
this.pendingCacheReads.add(agentId);
|
||||
return this.captureTimeline(agentId);
|
||||
}
|
||||
|
||||
commitCached(agents: Map<string, Agent>): void {
|
||||
const current = new Map(
|
||||
[...agents].filter(([id]) => !this.lifecycleVersions.has(id) || this.members.has(id)),
|
||||
);
|
||||
const merged = this.storeProjection.commitCached(current);
|
||||
const merged = this.storeProjection.commitCached(agents);
|
||||
this.members.clear();
|
||||
for (const agentId of merged.keys()) {
|
||||
this.members.add(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
commitCachedAgent(token: AgentLifecycleToken, agent: Agent): boolean {
|
||||
this.pendingCacheReads.delete(token.agentId);
|
||||
if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) return false;
|
||||
if (this.members.has(agent.id)) return false;
|
||||
this.members.add(agent.id);
|
||||
this.storeProjection.accept(agent);
|
||||
this.storeProjection.publishActivity(agent);
|
||||
return true;
|
||||
}
|
||||
|
||||
submitTimelineAgent(token: AgentLifecycleToken, payload: AgentSnapshotPayload): boolean {
|
||||
if (!this.isTimelineCurrent(token)) {
|
||||
if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) {
|
||||
return false;
|
||||
}
|
||||
const startsLifetime = !this.members.has(token.agentId);
|
||||
const existing = this.storeProjection.get(token.agentId);
|
||||
const timelineAgent = applyLegacyDaemonWorkspaceOwnership({
|
||||
serverId: this.serverId,
|
||||
@@ -76,10 +81,6 @@ export class AgentDirectoryReplica {
|
||||
projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement,
|
||||
};
|
||||
const accepted = this.storeProjection.accept(normalized);
|
||||
if (startsLifetime) {
|
||||
this.resume(accepted.id);
|
||||
this.acceptTimeline(accepted.id);
|
||||
}
|
||||
this.members.add(accepted.id);
|
||||
this.storeProjection.replacePendingPermissions(accepted);
|
||||
this.storeProjection.publishActivity(accepted);
|
||||
@@ -98,10 +99,7 @@ export class AgentDirectoryReplica {
|
||||
this.advance(delta.agentId);
|
||||
} else {
|
||||
this.members.add(delta.agent.id);
|
||||
if (!before) {
|
||||
this.resume(delta.agent.id);
|
||||
this.acceptTimeline(delta.agent.id);
|
||||
}
|
||||
if (!before) this.advance(delta.agent.id);
|
||||
}
|
||||
if (result.stoppedRunning) this.onStoppedRunning(result.agentId);
|
||||
this.persist(
|
||||
@@ -112,12 +110,7 @@ export class AgentDirectoryReplica {
|
||||
}
|
||||
|
||||
accept(agent: Agent): Agent {
|
||||
const startsLifetime = !this.members.has(agent.id);
|
||||
const accepted = this.storeProjection.accept(agent);
|
||||
if (startsLifetime) {
|
||||
this.resume(accepted.id);
|
||||
this.acceptTimeline(accepted.id);
|
||||
}
|
||||
this.members.add(accepted.id);
|
||||
this.persist([this.agentUpsert(accepted)]);
|
||||
return accepted;
|
||||
@@ -131,23 +124,22 @@ export class AgentDirectoryReplica {
|
||||
const previous = this.storeProjection.snapshot();
|
||||
const reconciled = reconcileAgentDirectory({ snapshot: entries, deltas });
|
||||
const nextIds = new Set(reconciled.map((entry) => entry.agent.id));
|
||||
const startedLifetimes = new Set<string>();
|
||||
for (const agentId of this.pendingCacheReads) {
|
||||
if (!nextIds.has(agentId)) this.advance(agentId);
|
||||
}
|
||||
for (const agentId of this.members) {
|
||||
if (!nextIds.has(agentId)) this.advance(agentId);
|
||||
}
|
||||
for (const agentId of nextIds) {
|
||||
if (!this.members.has(agentId)) {
|
||||
this.resume(agentId);
|
||||
startedLifetimes.add(agentId);
|
||||
}
|
||||
if (!this.members.has(agentId)) this.advance(agentId);
|
||||
}
|
||||
for (const agentId of previous.keys()) {
|
||||
if (!nextIds.has(agentId)) this.storeProjection.remove(agentId, "scope");
|
||||
if (!nextIds.has(agentId)) this.storeProjection.remove(agentId);
|
||||
}
|
||||
this.members.clear();
|
||||
this.pendingCacheReads.clear();
|
||||
for (const agentId of nextIds) this.members.add(agentId);
|
||||
const agents = this.storeProjection.replaceFetched(reconciled);
|
||||
for (const agentId of startedLifetimes) this.acceptTimeline(agentId);
|
||||
for (const [agentId, previousAgent] of previous) {
|
||||
if (previousAgent.turn.phase === "open" && agents.get(agentId)?.turn.phase === "idle") {
|
||||
this.onStoppedRunning(agentId);
|
||||
@@ -209,7 +201,7 @@ export class AgentDirectoryReplica {
|
||||
remove(agentId: string): void {
|
||||
this.members.delete(agentId);
|
||||
this.advance(agentId);
|
||||
this.storeProjection.remove(agentId, "deleted");
|
||||
this.storeProjection.remove(agentId);
|
||||
this.persist([{ kind: "agent", type: "delete", id: agentId }]);
|
||||
}
|
||||
|
||||
@@ -233,12 +225,6 @@ export class AgentDirectoryReplica {
|
||||
};
|
||||
}
|
||||
|
||||
private resume(agentId: string): void {
|
||||
// First admission keeps requests already in flight. Re-entry invalidates work issued
|
||||
// during the excluded lifetime as well as work issued before its removal.
|
||||
if (this.lifecycleVersions.has(agentId)) this.advance(agentId);
|
||||
}
|
||||
|
||||
private advance(agentId: string): void {
|
||||
this.lifecycleVersions.set(agentId, (this.lifecycleVersions.get(agentId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import type {
|
||||
FetchAgentsEntry,
|
||||
FetchAgentsOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
|
||||
import {
|
||||
normalizeProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
shouldUseLegacyDaemonWorkspaceDirectory,
|
||||
stampLegacyWorkspaceIds,
|
||||
} from "@/workspace/legacy-daemon-workspaces";
|
||||
import type { AgentDirectoryDelta, AgentRemovalReason } from "@/utils/agent-directory-sync";
|
||||
import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
||||
import { AgentDirectoryReplica } from "./agent-replica";
|
||||
import {
|
||||
WorkspaceDirectoryReplica,
|
||||
@@ -32,14 +33,13 @@ import {
|
||||
import { workspaceLabels } from "@/workspace-labels";
|
||||
import type {
|
||||
CachedDirectory,
|
||||
CachedWorkspace,
|
||||
DirectoryCheckpoint,
|
||||
DirectoryCursor,
|
||||
DirectoryReplicaMutation,
|
||||
} from "@/runtime/replica-cache";
|
||||
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||
|
||||
type TimelinePage = Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>;
|
||||
|
||||
const PAGE_LIMIT = 200;
|
||||
const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
|
||||
{ key: "updated_at", direction: "desc" },
|
||||
@@ -108,6 +108,8 @@ export interface DirectoryConnection {
|
||||
}
|
||||
|
||||
export interface DirectoryCheckpointStorage {
|
||||
readAgent(serverId: string, agentId: string): Promise<Agent | undefined>;
|
||||
readWorkspace(serverId: string, workspaceId: string): Promise<CachedWorkspace | undefined>;
|
||||
readDirectory(serverId: string): Promise<CachedDirectory>;
|
||||
commitDirectoryMutations(
|
||||
serverId: string,
|
||||
@@ -137,7 +139,6 @@ export class DirectorySync {
|
||||
WorkspaceDirectorySnapshot,
|
||||
WorkspaceDirectoryDelta
|
||||
>();
|
||||
private readonly timelineRequests = new Map<string, Promise<TimelinePage>>();
|
||||
private readonly agents: AgentDirectoryReplica;
|
||||
private readonly workspaces: WorkspaceDirectoryReplica;
|
||||
private connection: DirectoryConnection = {
|
||||
@@ -147,10 +148,10 @@ export class DirectorySync {
|
||||
};
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private readonly abortSessionWaits = new Set<() => void>();
|
||||
// The saved baseline is restored once, at construction. Live mutations apply as they arrive and
|
||||
// the baseline merges under them; network refreshes wait for it so their transactions and
|
||||
// cursors start from the saved state.
|
||||
private readonly baseline: Promise<void>;
|
||||
private cacheLoad: Promise<void> | null = null;
|
||||
private cacheAccepted = false;
|
||||
private revision = 0;
|
||||
private workspaceRevision = 0;
|
||||
private readonly routeDemandIds = new Set<string>();
|
||||
private readonly fullDemandSources = new Set<object>();
|
||||
private demandRefresh: Promise<void> | null = null;
|
||||
@@ -160,10 +161,7 @@ export class DirectorySync {
|
||||
constructor(
|
||||
private readonly serverId: string,
|
||||
private readonly callbacks: {
|
||||
onTimelineRequest: (agentId: string, replaceTail: boolean) => (page: TimelinePage) => void;
|
||||
onAgentStoppedRunning: (agentId: string) => void;
|
||||
onAgentRemoved: (agentId: string, reason: AgentRemovalReason) => void;
|
||||
onAgentAccepted?: (agentId: string) => void;
|
||||
markAgentLoading: () => void;
|
||||
markAgentReady: () => void;
|
||||
markAgentError: (error: string) => void;
|
||||
@@ -172,19 +170,8 @@ export class DirectorySync {
|
||||
) {
|
||||
const persist = (mutations: readonly DirectoryReplicaMutation[]) =>
|
||||
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
|
||||
this.agents = new AgentDirectoryReplica(
|
||||
serverId,
|
||||
callbacks.onAgentStoppedRunning,
|
||||
persist,
|
||||
callbacks.onAgentRemoved,
|
||||
callbacks.onAgentAccepted,
|
||||
);
|
||||
this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist);
|
||||
this.workspaces = new WorkspaceDirectoryReplica(serverId);
|
||||
this.baseline = this.restoreBaseline().catch(() => undefined);
|
||||
}
|
||||
|
||||
ready(): Promise<void> {
|
||||
return this.baseline;
|
||||
}
|
||||
|
||||
connectionChanged(connection: DirectoryConnection): boolean {
|
||||
@@ -198,7 +185,6 @@ export class DirectorySync {
|
||||
return false;
|
||||
}
|
||||
this.flushAbortedTransactions();
|
||||
this.timelineRequests.clear();
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
workspaceLabels.disconnect(this.serverId);
|
||||
@@ -210,39 +196,63 @@ export class DirectorySync {
|
||||
const subscriptions = [
|
||||
client.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
|
||||
if (!this.agentTransactions.record(source, message.payload)) {
|
||||
this.revision += 1;
|
||||
const recorded = this.agentTransactions.record(source, message.payload);
|
||||
if (!recorded) {
|
||||
this.agents.applyDelta(message.payload);
|
||||
this.noteLiveCursor("agents", message.payload);
|
||||
this.persistCheckpoint();
|
||||
}
|
||||
}),
|
||||
client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return;
|
||||
if (!this.workspaceTransactions.record(source, message.payload)) {
|
||||
this.revision += 1;
|
||||
this.workspaceRevision += 1;
|
||||
const recorded = this.workspaceTransactions.record(source, message.payload);
|
||||
if (!recorded) {
|
||||
this.applyWorkspaceDelta(message.payload);
|
||||
this.noteLiveCursor("workspaces", message.payload);
|
||||
this.persistCheckpoint();
|
||||
}
|
||||
}),
|
||||
client.on("project.update", (message) => {
|
||||
if (message.type !== "project.update" || !this.isCurrent(client, source)) return;
|
||||
if (!this.workspaceTransactions.record(source, message.payload)) {
|
||||
this.revision += 1;
|
||||
this.workspaceRevision += 1;
|
||||
const recorded = this.workspaceTransactions.record(source, message.payload);
|
||||
if (!recorded) {
|
||||
this.applyWorkspaceDelta(message.payload);
|
||||
this.noteLiveCursor("projects", message.payload);
|
||||
this.persistCheckpoint();
|
||||
}
|
||||
}),
|
||||
client.on("script_status_update", (message) => {
|
||||
if (message.type !== "script_status_update" || !this.isCurrent(client, source)) return;
|
||||
this.revision += 1;
|
||||
this.workspaceRevision += 1;
|
||||
const delta: WorkspaceDirectoryDelta = {
|
||||
kind: "script_status",
|
||||
update: message.payload,
|
||||
};
|
||||
if (!this.workspaceTransactions.record(source, delta)) {
|
||||
const recorded = this.workspaceTransactions.record(source, delta);
|
||||
if (!recorded) {
|
||||
this.applyWorkspaceDelta(delta);
|
||||
this.persistCheckpoint();
|
||||
}
|
||||
}),
|
||||
client.on("agent_deleted", (message) => {
|
||||
if (message.type !== "agent_deleted" || !this.isCurrent(client, source)) return;
|
||||
this.agents.remove(message.payload.agentId);
|
||||
if (message.type === "agent_deleted" && this.isCurrent(client, source)) {
|
||||
this.revision += 1;
|
||||
this.agents.remove(message.payload.agentId);
|
||||
this.persistCheckpoint();
|
||||
}
|
||||
}),
|
||||
client.on("agent_archived", (message) => {
|
||||
if (message.type !== "agent_archived" || !this.isCurrent(client, source)) return;
|
||||
this.agents.archive(message.payload.agentId, message.payload.archivedAt);
|
||||
if (message.type === "agent_archived" && this.isCurrent(client, source)) {
|
||||
this.revision += 1;
|
||||
this.agents.archive(message.payload.agentId, message.payload.archivedAt);
|
||||
this.persistCheckpoint();
|
||||
}
|
||||
}),
|
||||
];
|
||||
this.unsubscribe = () => {
|
||||
@@ -256,8 +266,9 @@ export class DirectorySync {
|
||||
const wasDemanded = this.fullDemandSources.size > 0;
|
||||
if (demanded) this.fullDemandSources.add(source);
|
||||
else this.fullDemandSources.delete(source);
|
||||
if (!wasDemanded && this.fullDemandSources.size > 0 && this.getOnlineConnection()) {
|
||||
void this.requestDemandRefresh().catch(() => undefined);
|
||||
if (!wasDemanded && this.fullDemandSources.size > 0) {
|
||||
void this.loadCachedDirectory().catch(() => undefined);
|
||||
if (this.getOnlineConnection()) void this.requestDemandRefresh().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,8 +288,6 @@ export class DirectorySync {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.connection = { ...this.connection, status: "offline" };
|
||||
this.timelineRequests.clear();
|
||||
this.flushAbortedTransactions();
|
||||
this.abortPendingSessionWaits();
|
||||
this.unsubscribe?.();
|
||||
@@ -295,7 +304,9 @@ export class DirectorySync {
|
||||
private requestDemandRefresh(force = false): Promise<void> {
|
||||
if (this.demandRefresh) return this.demandRefresh;
|
||||
if (!this.hasDemand()) return Promise.resolve();
|
||||
if (!this.getOnlineConnection()) return this.baseline;
|
||||
if (!this.getOnlineConnection()) {
|
||||
return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve();
|
||||
}
|
||||
const source = this.connection.source;
|
||||
if (
|
||||
!force &&
|
||||
@@ -308,8 +319,8 @@ export class DirectorySync {
|
||||
this.fullDemandSources.size > 0
|
||||
? this.refreshAll()
|
||||
: Promise.all([
|
||||
this.refreshAgents({ subscribe: {} }),
|
||||
this.refreshWorkspaces({ subscribe: true }),
|
||||
this.refreshAgentsInternal({ subscribe: {} }, false),
|
||||
this.refreshWorkspacesInternal({ subscribe: true }, false),
|
||||
]).then(() => undefined);
|
||||
this.demandRefresh = refresh
|
||||
.then(() => {
|
||||
@@ -334,13 +345,57 @@ export class DirectorySync {
|
||||
return this.requestDemandRefresh(true);
|
||||
}
|
||||
|
||||
private async restoreBaseline(): Promise<void> {
|
||||
async loadCachedAgent(agentId: string): Promise<void> {
|
||||
if (!this.checkpoints) return;
|
||||
const cached = await this.checkpoints.readDirectory(this.serverId);
|
||||
if (!useSessionStore.getState().sessions[this.serverId]) return;
|
||||
this.agents.commitCached(cached.agents);
|
||||
this.workspaces.commitCached(cached);
|
||||
this.cursors = cached.checkpoint ?? {};
|
||||
if (useSessionStore.getState().sessions[this.serverId]?.agents.has(agentId)) return;
|
||||
const token = this.agents.captureCache(agentId);
|
||||
const agent = await this.checkpoints.readAgent(this.serverId, agentId);
|
||||
if (!agent) return;
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
if (!session || session.agents.has(agentId)) return;
|
||||
this.agents.commitCachedAgent(token, agent);
|
||||
}
|
||||
|
||||
async prepareAgentRoute(agentId: string): Promise<void> {
|
||||
await this.loadCachedAgent(agentId);
|
||||
const agent = useSessionStore.getState().sessions[this.serverId]?.agents.get(agentId);
|
||||
if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId);
|
||||
}
|
||||
|
||||
async prepareWorkspaceRoute(workspaceId: string): Promise<void> {
|
||||
await this.loadCachedWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
private async loadCachedWorkspace(workspaceId: string): Promise<void> {
|
||||
if (!this.checkpoints) return;
|
||||
if (useSessionStore.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
|
||||
const revision = this.workspaceRevision;
|
||||
const cached = await this.checkpoints.readWorkspace(this.serverId, workspaceId);
|
||||
if (!cached) return;
|
||||
if (this.workspaceRevision !== revision) return;
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
if (!session) return;
|
||||
this.workspaces.commitCachedWorkspace(cached.workspace, cached.project);
|
||||
}
|
||||
|
||||
private loadCachedDirectory(): Promise<void> {
|
||||
const checkpoints = this.checkpoints;
|
||||
if (!checkpoints) return Promise.resolve();
|
||||
this.cacheLoad ??= (async () => {
|
||||
const revision = this.revision;
|
||||
const cached = await checkpoints.readDirectory(this.serverId);
|
||||
if (this.cacheAccepted) return;
|
||||
if (!useSessionStore.getState().sessions[this.serverId]) return;
|
||||
this.agents.commitCached(cached.agents);
|
||||
this.workspaces.commitCached(cached);
|
||||
if (this.revision === revision) this.cursors = cached.checkpoint ?? {};
|
||||
this.cacheAccepted = true;
|
||||
})();
|
||||
return this.cacheLoad;
|
||||
}
|
||||
|
||||
restoreCachedDirectory(): Promise<void> {
|
||||
return this.loadCachedDirectory();
|
||||
}
|
||||
|
||||
applyAgentTurnLiveness(
|
||||
@@ -357,36 +412,33 @@ export class DirectorySync {
|
||||
async fetchTimeline(
|
||||
agentId: string,
|
||||
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
|
||||
replaceTail = false,
|
||||
): Promise<TimelinePage> {
|
||||
const { client, source } = this.requireOnline();
|
||||
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
|
||||
const { client } = this.requireOnline();
|
||||
const token = this.agents.captureTimeline(agentId);
|
||||
const key = JSON.stringify({ agentId, lifetime: token.version, request, replaceTail });
|
||||
const existing = this.timelineRequests.get(key);
|
||||
if (existing) return existing;
|
||||
const apply = this.callbacks.onTimelineRequest(agentId, replaceTail);
|
||||
const fetching = client.fetchAgentTimeline(agentId, request).then((page) => {
|
||||
if (!this.isCurrent(client, source) || !this.agents.isTimelineCurrent(token)) {
|
||||
throw new DirectoryRefreshSupersededError("timeline page no longer current");
|
||||
}
|
||||
if (page.agent) this.agents.submitTimelineAgent(token, page.agent);
|
||||
apply(page);
|
||||
return page;
|
||||
});
|
||||
this.timelineRequests.set(key, fetching);
|
||||
const clear = () => {
|
||||
if (this.timelineRequests.get(key) === fetching) this.timelineRequests.delete(key);
|
||||
};
|
||||
void fetching.then(clear, clear);
|
||||
return fetching;
|
||||
const page = await fetchAgentTimelineOnce(client, agentId, request);
|
||||
if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) {
|
||||
this.revision += 1;
|
||||
this.persistCheckpoint();
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
async refreshAgents(
|
||||
input: RefreshAgentDirectoryInput = {},
|
||||
): Promise<RefreshAgentDirectoryResult> {
|
||||
return this.refreshAgentsInternal(input, true);
|
||||
}
|
||||
|
||||
private async refreshAgentsInternal(
|
||||
input: RefreshAgentDirectoryInput,
|
||||
loadDirectoryCache: boolean,
|
||||
): Promise<RefreshAgentDirectoryResult> {
|
||||
if (loadDirectoryCache) {
|
||||
this.primeAgentTransactionForCacheLoad();
|
||||
await this.loadCachedDirectory();
|
||||
}
|
||||
const onlineConnection = this.getOnlineConnection();
|
||||
if (!onlineConnection) {
|
||||
await this.baseline;
|
||||
this.callbacks.markAgentReady();
|
||||
return {
|
||||
agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents),
|
||||
@@ -394,8 +446,6 @@ export class DirectorySync {
|
||||
};
|
||||
}
|
||||
const { client, source } = onlineConnection;
|
||||
// The transaction spans the whole refresh, so a live delta that arrives while the saved
|
||||
// baseline or the session is still pending is replayed on top of whatever snapshot lands.
|
||||
const transaction = this.agentTransactions.begin(source, () => ({
|
||||
entries: [],
|
||||
subscriptionId: null,
|
||||
@@ -404,7 +454,6 @@ export class DirectorySync {
|
||||
}));
|
||||
this.callbacks.markAgentLoading();
|
||||
try {
|
||||
await this.baseline;
|
||||
await this.waitForSession(client, source);
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) {
|
||||
@@ -461,8 +510,9 @@ export class DirectorySync {
|
||||
: delta,
|
||||
)
|
||||
: completion.deltas;
|
||||
this.revision += 1;
|
||||
const agents = this.commitAgentSnapshot(completion.snapshot, deltas);
|
||||
this.persistAgentCursors(completion.snapshot);
|
||||
this.persistAgentCursors(completion.snapshot, completion.deltas);
|
||||
this.persistCheckpoint();
|
||||
this.callbacks.markAgentReady();
|
||||
return { agents, subscriptionId: completion.snapshot.subscriptionId };
|
||||
@@ -477,27 +527,26 @@ export class DirectorySync {
|
||||
}
|
||||
|
||||
async refreshWorkspaces(input?: { subscribe?: boolean }): Promise<void> {
|
||||
return this.refreshWorkspacesInternal(input, true);
|
||||
}
|
||||
|
||||
private async refreshWorkspacesInternal(
|
||||
input: { subscribe?: boolean } | undefined,
|
||||
loadDirectoryCache: boolean,
|
||||
): Promise<void> {
|
||||
if (loadDirectoryCache) await this.loadCachedDirectory();
|
||||
const onlineConnection = this.getOnlineConnection();
|
||||
if (!onlineConnection) {
|
||||
await this.baseline;
|
||||
return;
|
||||
}
|
||||
if (!onlineConnection) return;
|
||||
const { client, source } = onlineConnection;
|
||||
// The transaction spans the whole refresh. Its base for a changes-mode page is the store once
|
||||
// the saved baseline is in it, so it is captured after that wait rather than at begin.
|
||||
const transaction = this.workspaceTransactions.begin(source, () => ({
|
||||
workspaces: new Map(),
|
||||
projects: new Map(),
|
||||
workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces),
|
||||
projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects),
|
||||
syncCursors: {},
|
||||
syncModes: {},
|
||||
touchedWorkspaceIds: new Set(),
|
||||
touchedProjectIds: new Set(),
|
||||
}));
|
||||
try {
|
||||
await this.baseline;
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
transaction.snapshot.workspaces = new Map(session?.workspaces);
|
||||
transaction.snapshot.projects = new Map(session?.projects);
|
||||
await this.waitForSessionMetadata(client, source);
|
||||
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
|
||||
if (serverInfo?.features?.workspaceMultiplicity !== true) {
|
||||
@@ -676,6 +725,17 @@ export class DirectorySync {
|
||||
return { client: this.connection.client, source: this.connection.source };
|
||||
}
|
||||
|
||||
private primeAgentTransactionForCacheLoad(): void {
|
||||
const connection = this.getOnlineConnection();
|
||||
if (!connection) return;
|
||||
this.agentTransactions.begin(connection.source, () => ({
|
||||
entries: [],
|
||||
subscriptionId: null,
|
||||
legacy: false,
|
||||
syncRemovals: [],
|
||||
}));
|
||||
}
|
||||
|
||||
private supportsDirectorySync(): boolean {
|
||||
return (
|
||||
useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync ===
|
||||
@@ -692,8 +752,12 @@ export class DirectorySync {
|
||||
: this.agents.commitSnapshot(snapshot.entries, deltas);
|
||||
}
|
||||
|
||||
private persistAgentCursors(snapshot: AgentSnapshot): void {
|
||||
private persistAgentCursors(
|
||||
snapshot: AgentSnapshot,
|
||||
deltas: readonly AgentDirectoryDelta[],
|
||||
): void {
|
||||
if (snapshot.syncCursor) this.writeCursor("agents", snapshot.syncCursor);
|
||||
for (const delta of deltas) this.noteLiveCursor("agents", delta);
|
||||
}
|
||||
|
||||
private async fetchProjectSnapshot(
|
||||
@@ -737,6 +801,8 @@ export class DirectorySync {
|
||||
if (completion.kind === "stale") {
|
||||
throw new DirectoryRefreshSupersededError("workspace completion was superseded");
|
||||
}
|
||||
this.revision += 1;
|
||||
this.workspaceRevision += 1;
|
||||
const previous = this.readWorkspaceState();
|
||||
const deltaMutations = this.workspaces.commitSnapshot(completion.snapshot, completion.deltas);
|
||||
const next = this.workspaces.snapshot();
|
||||
@@ -756,11 +822,14 @@ export class DirectorySync {
|
||||
for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) {
|
||||
if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor);
|
||||
}
|
||||
for (const delta of completion.deltas) {
|
||||
if (delta.kind === "script_status") continue;
|
||||
const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces";
|
||||
this.noteLiveCursor(entity, delta);
|
||||
}
|
||||
this.persistCheckpoint();
|
||||
}
|
||||
|
||||
// Only a completed catch-up may advance the saved cursor. A live sequence can follow an offline
|
||||
// gap, so saving it would claim intermediate changes the client never received.
|
||||
private persistCheckpoint(): void {
|
||||
this.checkpoints?.commitDirectoryMutations(this.serverId, [], this.cursors);
|
||||
}
|
||||
@@ -805,6 +874,17 @@ export class DirectorySync {
|
||||
snapshot.syncCursor = { generation: sync.generation, afterSeq: sync.headSeq };
|
||||
}
|
||||
|
||||
private noteLiveCursor(
|
||||
entity: keyof DirectoryCheckpoint,
|
||||
payload: { generation?: string; seq?: number },
|
||||
): void {
|
||||
if (!payload.generation || payload.seq === undefined) return;
|
||||
this.writeCursor(entity, {
|
||||
generation: payload.generation,
|
||||
afterSeq: payload.seq,
|
||||
});
|
||||
}
|
||||
|
||||
private readCursors(): DirectoryCheckpoint {
|
||||
return this.cursors;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,7 @@ import equal from "fast-deep-equal";
|
||||
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy";
|
||||
import {
|
||||
buildAgentDirectoryState,
|
||||
type AgentDirectoryDelta,
|
||||
type AgentRemovalReason,
|
||||
} from "@/utils/agent-directory-sync";
|
||||
import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
@@ -27,10 +23,7 @@ function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent
|
||||
}
|
||||
|
||||
export class AgentStoreProjection {
|
||||
constructor(
|
||||
private readonly serverId: string,
|
||||
private readonly removeTimeline: (agentId: string, reason: AgentRemovalReason) => void,
|
||||
) {}
|
||||
constructor(private readonly serverId: string) {}
|
||||
|
||||
snapshot(): Map<string, Agent> {
|
||||
return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
|
||||
@@ -72,7 +65,7 @@ export class AgentStoreProjection {
|
||||
agent?: Agent;
|
||||
} {
|
||||
if (delta.kind === "remove") {
|
||||
this.remove(delta.agentId, "scope");
|
||||
this.remove(delta.agentId);
|
||||
return { agentId: delta.agentId, stoppedRunning: false };
|
||||
}
|
||||
const normalized = normalizeAgentSnapshot(delta.agent, this.serverId);
|
||||
@@ -148,7 +141,7 @@ export class AgentStoreProjection {
|
||||
useSessionStore.getState().setPendingPermissions(this.serverId, pending);
|
||||
}
|
||||
|
||||
remove(agentId: string, reason: AgentRemovalReason): void {
|
||||
remove(agentId: string): void {
|
||||
const store = useSessionStore.getState();
|
||||
const removeKey = <T>(current: Map<string, T>): Map<string, T> => {
|
||||
if (!current.has(agentId)) return current;
|
||||
@@ -160,13 +153,16 @@ export class AgentStoreProjection {
|
||||
store.setAgents(this.serverId, removeKey);
|
||||
store.setAgentDetails(this.serverId, removeKey);
|
||||
store.setQueuedMessages(this.serverId, removeKey);
|
||||
this.removeTimeline(agentId, reason);
|
||||
store.setAgentTimelineCursor(this.serverId, removeKey);
|
||||
store.setInitializingAgents(this.serverId, removeKey);
|
||||
store.setPendingPermissions(this.serverId, (current) => {
|
||||
const next = new Map(current);
|
||||
for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key);
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
|
||||
store.setAgentStreamTail(this.serverId, removeKey);
|
||||
store.clearAgentStreamHead(this.serverId, agentId);
|
||||
useSessionStore.setState((state) => {
|
||||
if (!state.agentLastActivity.has(agentId)) return state;
|
||||
const agentLastActivity = new Map(state.agentLastActivity);
|
||||
|
||||
@@ -213,7 +213,7 @@ it("does not invent a null-key project from a workspace update", () => {
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
it("does not restore a cached workspace while its archive is pending", () => {
|
||||
it("does not restore a targeted cached workspace while its archive is pending", () => {
|
||||
const serverId = "cached-workspace-during-archive";
|
||||
const workspaceId = "archived-workspace";
|
||||
const store = useSessionStore.getState();
|
||||
@@ -222,10 +222,7 @@ it("does not restore a cached workspace while its archive is pending", () => {
|
||||
markWorkspaceArchivePending({ serverId, workspaceId });
|
||||
|
||||
try {
|
||||
replica.commitCached({
|
||||
workspaces: new Map([[workspaceId, normalizeWorkspaceDescriptor(workspace(workspaceId))]]),
|
||||
projects: new Map(),
|
||||
});
|
||||
replica.commitCachedWorkspace(normalizeWorkspaceDescriptor(workspace(workspaceId)), undefined);
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
|
||||
} finally {
|
||||
|
||||
@@ -58,6 +58,15 @@ export class WorkspaceDirectoryReplica {
|
||||
useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true);
|
||||
}
|
||||
|
||||
commitCachedWorkspace(
|
||||
workspace: WorkspaceDescriptor,
|
||||
project: ProjectDescriptor | undefined,
|
||||
): void {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) return;
|
||||
if (project) this.setProject(project);
|
||||
this.setWorkspace(workspace);
|
||||
}
|
||||
|
||||
commitSnapshot(
|
||||
snapshot: WorkspaceDirectorySnapshot,
|
||||
deltas: readonly WorkspaceDirectoryDelta[],
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
type HostRuntimeStorage,
|
||||
} from "./host-runtime";
|
||||
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
||||
import { ReplicaCache } from "./replica-cache";
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
|
||||
class FakeDaemonClient {
|
||||
private state: ConnectionState = { status: "idle" };
|
||||
@@ -39,13 +37,6 @@ class FakeDaemonClient {
|
||||
public fetchAgentsResponses: Array<
|
||||
Awaited<ReturnType<DaemonClient["fetchAgents"]>> | ReturnType<DaemonClient["fetchAgents"]>
|
||||
> = [];
|
||||
public timelineCalls = 0;
|
||||
public timelineResponse: ReturnType<DaemonClient["fetchAgentTimeline"]> | null = null;
|
||||
fetchAgentTimeline(): ReturnType<DaemonClient["fetchAgentTimeline"]> {
|
||||
this.timelineCalls++;
|
||||
if (!this.timelineResponse) throw new Error("Missing timeline response");
|
||||
return this.timelineResponse;
|
||||
}
|
||||
public sentAgentMessages: Array<Parameters<DaemonClient["sendAgentMessage"]>> = [];
|
||||
public sendAgentMessageFailures: Error[] = [];
|
||||
public sendAgentMessageResponses: Promise<void>[] = [];
|
||||
@@ -475,13 +466,16 @@ function createMemoryReplicaRowStore(): ReplicaRowStore {
|
||||
`${row.serverId}:${row.kind}:${row.id}`;
|
||||
return {
|
||||
open: async () => undefined,
|
||||
read: async (serverIds, kinds, ids) =>
|
||||
[...rows.values()].filter(
|
||||
read: async (serverId, kinds, ids) => {
|
||||
const acceptedKinds = new Set(kinds);
|
||||
const acceptedIds = ids ? new Set(ids) : null;
|
||||
return [...rows.values()].filter(
|
||||
(row) =>
|
||||
serverIds.includes(row.serverId) &&
|
||||
kinds.includes(row.kind) &&
|
||||
(!ids || ids.includes(row.id)),
|
||||
),
|
||||
row.serverId === serverId &&
|
||||
acceptedKinds.has(row.kind) &&
|
||||
(!acceptedIds || acceptedIds.has(row.id)),
|
||||
);
|
||||
},
|
||||
readAll: async () => {
|
||||
const hosts = new Map<string, ReplicaRow[]>();
|
||||
for (const row of rows.values()) {
|
||||
@@ -1605,74 +1599,15 @@ describe("HostRuntimeStore", () => {
|
||||
expect(store.getHosts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("exposes timeline demand and paints saved history before a network client exists", async () => {
|
||||
const host = makeHost();
|
||||
const storage = createMemoryHostRuntimeStorage({
|
||||
"@paseo:daemon-registry": JSON.stringify([host]),
|
||||
"@paseo:e2e": "1",
|
||||
});
|
||||
const replicaRowStore = createMemoryReplicaRowStore();
|
||||
const disk = new ReplicaCache(replicaRowStore, { clearLegacyCache: async () => undefined });
|
||||
disk.setHosts([host.serverId]);
|
||||
disk.commitTimeline(host.serverId, "offline-agent", {
|
||||
agentId: "offline-agent",
|
||||
hasOlder: false,
|
||||
range: { epoch: "offline-epoch", startSeq: 1, endSeq: 1 },
|
||||
items: [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "saved-message",
|
||||
messageId: "saved-message",
|
||||
text: "Saved history is available offline",
|
||||
timestamp: new Date("2026-09-06T10:00:00.000Z"),
|
||||
timelineCursor: { epoch: "offline-epoch", seq: 1 },
|
||||
source: { startSeq: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
await disk.flush();
|
||||
const store = new HostRuntimeStore({
|
||||
storage,
|
||||
replicaRowStore,
|
||||
deps: {
|
||||
createClient: () => {
|
||||
throw new Error("Offline demand must not need a client");
|
||||
},
|
||||
connectToDaemon: async () => {
|
||||
throw new Error("Offline demand must not need a connection");
|
||||
},
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded());
|
||||
store.boot();
|
||||
await registryLoaded;
|
||||
expect(useSessionStore.getState().sessions[host.serverId]?.client).toBeNull();
|
||||
const demand = useSessionStore.getState().sessions[host.serverId]?.viewedTimelineSync;
|
||||
expect(demand).not.toBeNull();
|
||||
if (!demand) throw new Error("Registered hosts must expose offline timeline demand");
|
||||
demand.registerVisibleAgentIds("workspace", ["offline-agent"]);
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
useSessionStore.getState().sessions[host.serverId]?.agentStreamTail.get("offline-agent"),
|
||||
).toMatchObject([{ text: "Saved history is available offline" }]);
|
||||
});
|
||||
} finally {
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
}
|
||||
});
|
||||
|
||||
it("loads the host registry and restores its cached directory", async () => {
|
||||
const host = makeHost();
|
||||
const storage = createMemoryHostRuntimeStorage();
|
||||
let directoryReads = 0;
|
||||
let fullScans = 0;
|
||||
const backingStore = createMemoryReplicaRowStore();
|
||||
const replicaRowStore: ReplicaRowStore = {
|
||||
...backingStore,
|
||||
read: async (...args) => {
|
||||
directoryReads += 1;
|
||||
fullScans += 1;
|
||||
return backingStore.read(...args);
|
||||
},
|
||||
};
|
||||
@@ -1697,7 +1632,7 @@ describe("HostRuntimeStore", () => {
|
||||
store.boot();
|
||||
await registryLoaded;
|
||||
|
||||
await vi.waitFor(() => expect(directoryReads).toBe(1));
|
||||
await vi.waitFor(() => expect(fullScans).toBe(1));
|
||||
expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({
|
||||
client: null,
|
||||
hasHydratedAgents: false,
|
||||
@@ -2394,15 +2329,10 @@ describe("HostRuntimeStore", () => {
|
||||
host.serverId,
|
||||
new Map([[recoveredAgent.agent.id, replicaAgent(recoveredAgent.agent, host.serverId)]]),
|
||||
);
|
||||
sessionStore.applyAgentTimelineResponseState(host.serverId, recoveredAgent.agent.id, {
|
||||
items: [],
|
||||
head: [],
|
||||
range: { epoch: "epoch", startSeq: 10, endSeq: 20 },
|
||||
older: "none",
|
||||
newer: false,
|
||||
synchronized: false,
|
||||
submissions: [],
|
||||
});
|
||||
sessionStore.setAgentTimelineCursor(
|
||||
host.serverId,
|
||||
new Map([[recoveredAgent.agent.id, { epoch: "epoch", startSeq: 10, endSeq: 20 }]]),
|
||||
);
|
||||
store.syncHosts([host]);
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
const load = store.refreshAgentDirectory({
|
||||
@@ -2825,9 +2755,8 @@ describe("HostRuntimeStore", () => {
|
||||
getClientId: async () => "cid_drain_submission",
|
||||
},
|
||||
});
|
||||
store.syncHosts([{ ...host, connections: [] }]);
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.updateSessionServerInfo(host.serverId, {
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
@@ -2873,7 +2802,7 @@ describe("HostRuntimeStore", () => {
|
||||
expect(session?.messageSubmissions.get("agent")).toBeDefined();
|
||||
|
||||
send.resolve();
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("restores an automatically drained message when sending fails", async () => {
|
||||
@@ -2891,9 +2820,8 @@ describe("HostRuntimeStore", () => {
|
||||
getClientId: async () => "cid_failed_queue_drain",
|
||||
},
|
||||
});
|
||||
store.syncHosts([{ ...host, connections: [] }]);
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([
|
||||
@@ -2919,7 +2847,7 @@ describe("HostRuntimeStore", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("serializes queued-message drains for the same agent", async () => {
|
||||
@@ -2938,9 +2866,8 @@ describe("HostRuntimeStore", () => {
|
||||
getClientId: async () => "cid_serialized_queue_drain",
|
||||
},
|
||||
});
|
||||
store.syncHosts([{ ...host, connections: [] }]);
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]),
|
||||
@@ -2957,7 +2884,7 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get("agent"),
|
||||
).toEqual([]);
|
||||
});
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => {
|
||||
@@ -2974,9 +2901,8 @@ describe("HostRuntimeStore", () => {
|
||||
getClientId: async () => "cid_legacy_queue_attachment",
|
||||
},
|
||||
});
|
||||
store.syncHosts([{ ...host, connections: [] }]);
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.updateSessionServerInfo(host.serverId, {
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
@@ -3215,235 +3141,6 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it.each(["coalesced", "late-tail", "rewind", "daemon-reset"] as const)(
|
||||
"owns timeline request application (%s)",
|
||||
async (scenario) => {
|
||||
const host = makeHost({
|
||||
serverId: `request-${scenario}`,
|
||||
connections: [makeHost().connections[0]!],
|
||||
});
|
||||
const client = new FakeDaemonClient();
|
||||
client.setConnectionState({ status: "connected" });
|
||||
const store = new HostRuntimeStore({
|
||||
replicaRowStore: createMemoryReplicaRowStore(),
|
||||
deps: {
|
||||
createClient: () => client as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host: current }) => ({
|
||||
client: client as unknown as DaemonClient,
|
||||
serverId: current.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "test",
|
||||
},
|
||||
});
|
||||
store.syncHosts([host]);
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
const page: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>> = {
|
||||
requestId: "page",
|
||||
agentId: "agent",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
epoch: "epoch",
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { epoch: "epoch", seq: 1 },
|
||||
endCursor: { epoch: "epoch", seq: 1 },
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
error: null,
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "first", messageId: "first" },
|
||||
timestamp: "2026-09-06T00:00:00Z",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const viewed = store.getViewedTimelineOwner(host.serverId);
|
||||
if (scenario !== "coalesced") viewed.applyTimelineResponse(page);
|
||||
const response = createDeferred<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>>();
|
||||
client.timelineResponse = response.promise;
|
||||
let publications = 0;
|
||||
const unsubscribe = useSessionStore.subscribe((state, previous) => {
|
||||
if (
|
||||
state.sessions[host.serverId]?.agentStreamTail !==
|
||||
previous.sessions[host.serverId]?.agentStreamTail
|
||||
)
|
||||
publications++;
|
||||
});
|
||||
const appendLive = () => {
|
||||
viewed.enqueueStreamEvent("agent", {
|
||||
epoch: "epoch",
|
||||
seq: 2,
|
||||
timestamp: new Date("2026-09-06T00:00:01Z"),
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "new live", messageId: "new-live" },
|
||||
},
|
||||
});
|
||||
viewed.flushStreamAgent("agent");
|
||||
};
|
||||
if (scenario === "rewind") appendLive();
|
||||
const request = planTimelineTailFetch();
|
||||
const first = store.fetchAgentTimeline(
|
||||
host.serverId,
|
||||
"agent",
|
||||
request,
|
||||
scenario === "late-tail",
|
||||
);
|
||||
const second =
|
||||
scenario === "coalesced"
|
||||
? store.fetchAgentTimeline(host.serverId, "agent", request)
|
||||
: first;
|
||||
if (scenario === "late-tail" || scenario === "daemon-reset") appendLive();
|
||||
if (scenario === "daemon-reset") page.reset = true;
|
||||
response.resolve(page);
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([page, page]);
|
||||
expect(client.timelineCalls).toBe(1);
|
||||
if (scenario === "coalesced") expect(publications).toBe(1);
|
||||
else {
|
||||
const session = useSessionStore.getState().sessions[host.serverId];
|
||||
expect(
|
||||
[
|
||||
...(session?.agentStreamTail.get("agent") ?? []),
|
||||
...(session?.agentStreamHead.get("agent") ?? []),
|
||||
].map((item) => item.id),
|
||||
).toEqual(scenario === "late-tail" ? ["first", "new-live"] : ["first"]);
|
||||
expect(store.getTimelineReplica(host.serverId).readCursor("agent")?.endSeq).toBe(
|
||||
scenario === "late-tail" ? 2 : 1,
|
||||
);
|
||||
}
|
||||
unsubscribe();
|
||||
store.syncHosts([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["settled", "captured", "renamed"] as const)(
|
||||
"retains scoped-out history after host identity reconciliation (%s) and deletes it only explicitly",
|
||||
async (ordering) => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_temporary",
|
||||
connections: [makeHost().connections[0]!],
|
||||
});
|
||||
const resolvedId = "srv_resolved";
|
||||
const rows = createMemoryReplicaRowStore();
|
||||
const seed = new ReplicaCache(rows, { clearLegacyCache: async () => undefined });
|
||||
seed.setHosts([host.serverId]);
|
||||
const saved = {
|
||||
agentId: "agent",
|
||||
hasOlder: false,
|
||||
range: { epoch: "epoch", startSeq: 1, endSeq: 1 },
|
||||
items: [
|
||||
{
|
||||
kind: "assistant_message" as const,
|
||||
id: "saved",
|
||||
text: "saved history",
|
||||
timestamp: new Date("2026-09-06T00:00:00Z"),
|
||||
timelineCursor: { epoch: "epoch", seq: 1 },
|
||||
source: { startSeq: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
seed.commitTimeline(host.serverId, "agent", saved);
|
||||
await seed.flush();
|
||||
const client = new FakeDaemonClient();
|
||||
client.setConnectionState({ status: "connected" });
|
||||
const store = new HostRuntimeStore({
|
||||
replicaRowStore: rows,
|
||||
deps: {
|
||||
createClient: () => client as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host: current }) => ({
|
||||
client: client as unknown as DaemonClient,
|
||||
serverId: current.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "test",
|
||||
},
|
||||
});
|
||||
const storedRows = async () =>
|
||||
(await rows.readAll()).find((entry) => entry.serverId === resolvedId)?.rows ?? [];
|
||||
const hasStoredKind = async (kind: ReplicaRow["kind"]) =>
|
||||
(await storedRows()).some((row) => row.kind === kind);
|
||||
try {
|
||||
store.syncHosts([host]);
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
const replica = store.getTimelineReplica(host.serverId);
|
||||
const started = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
const readRows = rows.read.bind(rows);
|
||||
let reads = 0;
|
||||
rows.read = async (...args) => {
|
||||
if (!args[1].includes("timeline")) return readRows(...args);
|
||||
reads++;
|
||||
if (ordering === "settled" || reads !== 1) return readRows(...args);
|
||||
const captured = ordering === "captured" ? await readRows(...args) : undefined;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return captured ?? readRows(...args);
|
||||
};
|
||||
const preparation = replica.prepare("agent");
|
||||
if (ordering === "settled") {
|
||||
await preparation;
|
||||
expect(
|
||||
useSessionStore.getState().sessions[host.serverId]?.agentStreamTail.get("agent"),
|
||||
).toEqual(saved.items);
|
||||
} else await started.promise;
|
||||
store.reconcileServerId(host.serverId, resolvedId);
|
||||
if (ordering !== "settled") {
|
||||
await vi.waitFor(async () => expect(await hasStoredKind("timeline")).toBe(true));
|
||||
release.resolve();
|
||||
await preparation;
|
||||
}
|
||||
expect(replica.prepare("agent")).toBe(preparation);
|
||||
expect(reads).toBe(ordering === "settled" ? 1 : 2);
|
||||
expect(store.getTimelineReplica(resolvedId)).toBe(replica);
|
||||
expect(
|
||||
useSessionStore.getState().sessions[resolvedId]?.agentStreamTail.get("agent"),
|
||||
).toEqual(saved.items);
|
||||
expect(replica.readCursor("agent")).toEqual({ epoch: "epoch", endSeq: 1 });
|
||||
await waitForHostOnline(store, resolvedId);
|
||||
useSessionStore.getState().updateSessionServerInfo(resolvedId, {
|
||||
serverId: resolvedId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true },
|
||||
});
|
||||
store.acceptAgentSnapshot(
|
||||
resolvedId,
|
||||
normalizeAgentSnapshot(
|
||||
makeFetchAgentsEntry({ id: "agent", cwd: "/repo", updatedAt: "2026-09-06T00:00:00Z" })
|
||||
.agent,
|
||||
resolvedId,
|
||||
),
|
||||
);
|
||||
await vi.waitFor(async () => expect(await hasStoredKind("agent")).toBe(true), {
|
||||
timeout: 3000,
|
||||
});
|
||||
await store.refreshAgentDirectory({ serverId: resolvedId });
|
||||
store.setAppVisible(false);
|
||||
await vi.waitFor(async () => expect(await hasStoredKind("agent")).toBe(false));
|
||||
const reopened = new ReplicaCache(rows, { clearLegacyCache: async () => undefined });
|
||||
reopened.setHosts([resolvedId]);
|
||||
expect(await reopened.readTimeline(resolvedId, "agent")).toEqual(saved);
|
||||
store.restoreAgentSnapshot(resolvedId, "agent", undefined);
|
||||
store.setAppVisible(false);
|
||||
await vi.waitFor(async () => expect(await hasStoredKind("timeline")).toBe(false));
|
||||
} finally {
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
useSessionStore.getState().clearSession(resolvedId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("replaces stale active session state when active bootstrap omits an agent", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_archived_rehydrate",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createTimelineReplica, type TimelineReplica } from "@/timeline/replica";
|
||||
import { useSyncExternalStore, useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
@@ -71,8 +70,11 @@ import { ReplicaCache } from "@/runtime/replica-cache";
|
||||
import type { ReplicaRowStore } from "@/runtime/replica-cache/row-store";
|
||||
import { createReplicaRowStore } from "@/runtime/replica-cache/row-store-factory";
|
||||
import {
|
||||
createTimelineReplica,
|
||||
createViewedTimelineOwner,
|
||||
type TimelineReplica,
|
||||
type ViewedTimelineOwner,
|
||||
type ViewedTimelineOwnerPorts,
|
||||
} from "@/timeline/viewed-timeline-sync";
|
||||
import { projectIconCache } from "@/projects/icon-cache";
|
||||
import { nativePerformanceTrace } from "@/performance/native-trace";
|
||||
@@ -1402,7 +1404,6 @@ export class HostRuntimeStore {
|
||||
private directorySyncByServer = new Map<string, DirectorySync>();
|
||||
private nextCancellationRequestId = 0;
|
||||
private timelineReplicaByServer = new Map<string, TimelineReplica>();
|
||||
private viewedTimelineByServer = new Map<string, ViewedTimelineOwner>();
|
||||
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
||||
private bootPromise: Promise<void> | null = null;
|
||||
private appVisible = true;
|
||||
@@ -1520,6 +1521,12 @@ export class HostRuntimeStore {
|
||||
projectIconCache.setHosts(profiles.map((profile) => profile.serverId));
|
||||
await projectIconCache.restore();
|
||||
this.syncHosts(profiles);
|
||||
for (const profile of profiles) {
|
||||
void this.directorySyncByServer
|
||||
.get(profile.serverId)
|
||||
?.restoreCachedDirectory()
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[HostRuntime] Failed to load host registry from storage", error);
|
||||
} finally {
|
||||
@@ -1656,17 +1663,11 @@ export class HostRuntimeStore {
|
||||
projectIconCache.reconcileServerId(oldServerId, newServerId);
|
||||
this.directorySyncByServer.get(oldServerId)?.dispose();
|
||||
this.directorySyncByServer.delete(oldServerId);
|
||||
const timelineReplica = this.getTimelineReplica(oldServerId);
|
||||
rekeyMap(this.timelineReplicaByServer, oldServerId, newServerId);
|
||||
this.timelineReplicaByServer.delete(oldServerId);
|
||||
const directory = new DirectorySync(
|
||||
newServerId,
|
||||
{
|
||||
onTimelineRequest: (agentId, replaceTail) =>
|
||||
this.getViewedTimelineOwner(newServerId).beginTimelineRequest(agentId, replaceTail),
|
||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
|
||||
onAgentRemoved: (agentId, reason) =>
|
||||
this.getViewedTimelineOwner(newServerId).removeAgent(agentId, reason),
|
||||
onAgentAccepted: (agentId) => this.getViewedTimelineOwner(newServerId).acceptAgent(agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
@@ -1674,16 +1675,18 @@ export class HostRuntimeStore {
|
||||
this.replicaCache,
|
||||
);
|
||||
this.directorySyncByServer.set(newServerId, directory);
|
||||
this.timelineReplicaByServer.set(
|
||||
newServerId,
|
||||
createTimelineReplica({
|
||||
serverId: newServerId,
|
||||
storage: this.replicaCache,
|
||||
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
|
||||
}),
|
||||
);
|
||||
controller.adoptReconciledServerId(newServerId);
|
||||
const snapshot = controller.getSnapshot();
|
||||
this.viewedTimelineByServer.get(oldServerId)?.dispose();
|
||||
this.viewedTimelineByServer.delete(oldServerId);
|
||||
this.clearHostReplica(oldServerId);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession(newServerId, snapshot.client, snapshot.clientGeneration);
|
||||
timelineReplica.reconcileServerId(newServerId);
|
||||
this.installViewedTimelineOwner(newServerId);
|
||||
this.syncSessionReplica(newServerId, snapshot);
|
||||
directory.connectionChanged({
|
||||
client: snapshot.client,
|
||||
status: snapshot.connectionStatus === "online" ? "online" : "offline",
|
||||
@@ -2054,9 +2057,6 @@ export class HostRuntimeStore {
|
||||
this.connectionStatusStartedAtByServer.delete(serverId);
|
||||
this.directorySyncByServer.get(serverId)?.dispose();
|
||||
this.directorySyncByServer.delete(serverId);
|
||||
this.viewedTimelineByServer.get(serverId)?.dispose();
|
||||
this.viewedTimelineByServer.delete(serverId);
|
||||
this.timelineReplicaByServer.get(serverId)?.dispose();
|
||||
this.timelineReplicaByServer.delete(serverId);
|
||||
this.clearHostReplica(serverId);
|
||||
void controller.stop();
|
||||
@@ -2086,13 +2086,7 @@ export class HostRuntimeStore {
|
||||
const directory = new DirectorySync(
|
||||
host.serverId,
|
||||
{
|
||||
onTimelineRequest: (agentId, replaceTail) =>
|
||||
this.getViewedTimelineOwner(host.serverId).beginTimelineRequest(agentId, replaceTail),
|
||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
|
||||
onAgentRemoved: (agentId, reason) =>
|
||||
this.getViewedTimelineOwner(host.serverId).removeAgent(agentId, reason),
|
||||
onAgentAccepted: (agentId) =>
|
||||
this.getViewedTimelineOwner(host.serverId).acceptAgent(agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
@@ -2105,9 +2099,9 @@ export class HostRuntimeStore {
|
||||
createTimelineReplica({
|
||||
serverId: host.serverId,
|
||||
storage: this.replicaCache,
|
||||
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
|
||||
}),
|
||||
);
|
||||
this.installViewedTimelineOwner(host.serverId);
|
||||
const initialSnapshot = controller.getSnapshot();
|
||||
this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus);
|
||||
this.connectionStatusStartedAtByServer.set(host.serverId, Date.now());
|
||||
@@ -2219,7 +2213,7 @@ export class HostRuntimeStore {
|
||||
supportsForgeAttachments,
|
||||
}),
|
||||
encodeImages,
|
||||
submission: createMessageSubmissionWriter(serverId, this.getTimelineReplica(serverId)),
|
||||
submission: createMessageSubmissionWriter(serverId),
|
||||
});
|
||||
},
|
||||
})
|
||||
@@ -2349,7 +2343,6 @@ export class HostRuntimeStore {
|
||||
|
||||
setAppVisible(visible: boolean): void {
|
||||
this.appVisible = visible;
|
||||
for (const owner of this.viewedTimelineByServer.values()) owner.setActive(visible);
|
||||
for (const controller of this.controllers.values()) {
|
||||
controller.setReconnectEnabled(visible);
|
||||
}
|
||||
@@ -2398,40 +2391,49 @@ export class HostRuntimeStore {
|
||||
return () => directory.setDemand(source, false);
|
||||
}
|
||||
|
||||
getTimelineReplica(serverId: string): TimelineReplica {
|
||||
const replica = this.timelineReplicaByServer.get(serverId);
|
||||
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
return replica;
|
||||
async prepareAgentRoute(serverId: string, agentId: string): Promise<void> {
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
await directory.prepareAgentRoute(agentId);
|
||||
}
|
||||
|
||||
async fetchAgentTimeline(
|
||||
async prepareWorkspaceRoute(serverId: string, workspaceId: string): Promise<void> {
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
await directory.prepareWorkspaceRoute(workspaceId);
|
||||
}
|
||||
|
||||
async prepareAgentTimeline(serverId: string, agentId: string): Promise<void> {
|
||||
const replica = this.timelineReplicaByServer.get(serverId);
|
||||
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
await replica.prepare(agentId);
|
||||
}
|
||||
|
||||
fetchAgentTimeline(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
|
||||
replaceTail = false,
|
||||
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
return directory.fetchTimeline(agentId, request, replaceTail);
|
||||
return directory.fetchTimeline(agentId, request);
|
||||
}
|
||||
|
||||
getViewedTimelineOwner(serverId: string): ViewedTimelineOwner {
|
||||
const owner = this.viewedTimelineByServer.get(serverId);
|
||||
if (!owner) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
return owner;
|
||||
}
|
||||
|
||||
private installViewedTimelineOwner(serverId: string): void {
|
||||
const owner = createViewedTimelineOwner({
|
||||
createViewedTimelineOwner(
|
||||
serverId: string,
|
||||
ports: ViewedTimelineOwnerPorts,
|
||||
): ViewedTimelineOwner {
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
const replica = this.timelineReplicaByServer.get(serverId);
|
||||
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
return createViewedTimelineOwner({
|
||||
serverId,
|
||||
replica: this.getTimelineReplica(serverId),
|
||||
replaceDemandedAgentIds: (ids) =>
|
||||
this.directorySyncByServer.get(serverId)?.setAgentRouteDemand(ids),
|
||||
drainQueuedAgentMessage: (id) => this.drainQueuedAgentMessage(serverId, id),
|
||||
replica,
|
||||
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
|
||||
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId),
|
||||
ports,
|
||||
});
|
||||
this.viewedTimelineByServer.set(serverId, owner);
|
||||
owner.setActive(this.appVisible);
|
||||
useSessionStore.getState().setViewedTimelineSync(serverId, owner);
|
||||
}
|
||||
|
||||
private emit(serverId: string): void {
|
||||
|
||||
@@ -25,58 +25,41 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
readonly rows = new Map<string, ReplicaRow>();
|
||||
readonly changes: ReplicaRowChanges[] = [];
|
||||
readonly reads: Array<{
|
||||
serverIds: readonly string[];
|
||||
serverId: string;
|
||||
kinds: readonly ReplicaRow["kind"][];
|
||||
ids?: readonly string[];
|
||||
}> = [];
|
||||
writes = 0;
|
||||
readAlls = 0;
|
||||
cleanups = 0;
|
||||
nextWriteFailure: Error | null = null;
|
||||
nextOpenFailure: Error | null = null;
|
||||
nextReadAllFailure: Error | null = null;
|
||||
nextDeleteFailure: Error | null = null;
|
||||
readGate: Promise<void> | null = null;
|
||||
readAllGate: Promise<void> | null = null;
|
||||
writeGate: Promise<void> | null = null;
|
||||
renameGate: Promise<void> | null = null;
|
||||
onRead: (() => void) | null = null;
|
||||
|
||||
private key(row: Pick<ReplicaRow, "serverId" | "kind" | "id">): string {
|
||||
return `${row.serverId}:${row.kind}:${row.id}`;
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
if (this.nextOpenFailure) {
|
||||
const error = this.nextOpenFailure;
|
||||
this.nextOpenFailure = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async open(): Promise<void> {}
|
||||
|
||||
async read(
|
||||
serverIds: readonly string[],
|
||||
serverId: string,
|
||||
kinds: readonly ReplicaRow["kind"][],
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
this.reads.push({ serverIds, kinds, ids });
|
||||
const rows = [...this.rows.values()];
|
||||
this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) });
|
||||
this.onRead?.();
|
||||
await this.readGate;
|
||||
return rows.filter(
|
||||
const acceptedKinds = new Set(kinds);
|
||||
const acceptedIds = ids ? new Set(ids) : null;
|
||||
return [...this.rows.values()].filter(
|
||||
(row) =>
|
||||
serverIds.includes(row.serverId) &&
|
||||
kinds.includes(row.kind) &&
|
||||
(!ids || ids.includes(row.id)),
|
||||
row.serverId === serverId &&
|
||||
acceptedKinds.has(row.kind) &&
|
||||
(!acceptedIds || acceptedIds.has(row.id)),
|
||||
);
|
||||
}
|
||||
|
||||
async readAll(): Promise<ReplicaHostRows[]> {
|
||||
this.readAlls += 1;
|
||||
await this.readAllGate;
|
||||
if (this.nextReadAllFailure) {
|
||||
const error = this.nextReadAllFailure;
|
||||
this.nextReadAllFailure = null;
|
||||
throw error;
|
||||
}
|
||||
const hosts = new Map<string, ReplicaRow[]>();
|
||||
for (const row of this.rows.values()) {
|
||||
const rows = hosts.get(row.serverId) ?? [];
|
||||
@@ -88,7 +71,6 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
|
||||
async apply(changes: ReplicaRowChanges): Promise<void> {
|
||||
this.writes += 1;
|
||||
await this.writeGate;
|
||||
if (this.nextWriteFailure) {
|
||||
const error = this.nextWriteFailure;
|
||||
this.nextWriteFailure = null;
|
||||
@@ -100,16 +82,10 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
}
|
||||
|
||||
async deleteHost(serverId: string): Promise<void> {
|
||||
if (this.nextDeleteFailure) {
|
||||
const error = this.nextDeleteFailure;
|
||||
this.nextDeleteFailure = null;
|
||||
throw error;
|
||||
}
|
||||
for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key);
|
||||
}
|
||||
|
||||
async renameHost(oldServerId: string, newServerId: string): Promise<void> {
|
||||
await this.renameGate;
|
||||
for (const [key, row] of this.rows) {
|
||||
if (row.serverId !== oldServerId) continue;
|
||||
this.rows.delete(key);
|
||||
@@ -194,7 +170,6 @@ function timelineItem(text = "Cached"): StreamItem {
|
||||
text,
|
||||
timestamp: new Date("2026-07-18T08:02:00.000Z"),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 12 },
|
||||
source: { startSeq: 12, chunks: [{ seq: 12, offset: 0 }] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,359 +241,6 @@ function deleteDirectory(cache: ReplicaCache, serverId: string): void {
|
||||
}
|
||||
|
||||
describe("ReplicaCache", () => {
|
||||
it("paints requested rows while the unrelated global index is unavailable", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
commitDirectory(writer, SERVER_ID, directory());
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Requested"));
|
||||
await writer.flush();
|
||||
const release = deferred();
|
||||
storage.readAllGate = release.promise;
|
||||
storage.reads.length = 0;
|
||||
const reader = createCache(storage);
|
||||
reader.setHosts([SERVER_ID, "unrelated"]);
|
||||
reader.commitTimeline("unrelated", "other", { ...timeline("Pending"), agentId: "other" });
|
||||
const writing = reader.flush();
|
||||
let painted = false;
|
||||
const reading = Promise.all([
|
||||
reader.readTimeline(SERVER_ID, "agent-1"),
|
||||
reader.readDirectory(SERVER_ID),
|
||||
]).then(([saved, restoredDirectory]) => {
|
||||
expect(saved?.items).toEqual([timelineItem("Requested")]);
|
||||
expect(restoredDirectory.agents.has("agent-1")).toBe(true);
|
||||
painted = true;
|
||||
return undefined;
|
||||
});
|
||||
try {
|
||||
await expect.poll(() => painted, { timeout: 200 }).toBe(true);
|
||||
expect(storage.reads).toEqual([
|
||||
{ serverIds: [SERVER_ID], kinds: ["timeline"], ids: ["agent-1"] },
|
||||
{
|
||||
serverIds: [SERVER_ID],
|
||||
kinds: ["agent", "workspace", "project", "checkpoint"],
|
||||
ids: undefined,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await Promise.all([writing, reading]);
|
||||
}
|
||||
});
|
||||
|
||||
it("overlays a commit that settles after a scoped read captures old disk rows", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Old"));
|
||||
await writer.flush();
|
||||
const reader = createCache(storage);
|
||||
const release = deferred();
|
||||
storage.readGate = release.promise;
|
||||
const reading = reader.readTimeline(SERVER_ID, "agent-1");
|
||||
await expect.poll(() => storage.reads.length).toBe(1);
|
||||
reader.commitTimeline(SERVER_ID, "agent-1", timeline("New"));
|
||||
await reader.flush();
|
||||
release.resolve();
|
||||
expect((await reading)?.items).toEqual([timelineItem("New")]);
|
||||
});
|
||||
|
||||
it("invalidates an old scoped read across removal and immediate re-registration", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Old lifetime"));
|
||||
await writer.flush();
|
||||
const reader = createCache(storage);
|
||||
const release = deferred();
|
||||
storage.readGate = release.promise;
|
||||
const reading = reader.readTimeline(SERVER_ID, "agent-1");
|
||||
await expect.poll(() => storage.reads.length).toBe(1);
|
||||
reader.setHosts([]);
|
||||
reader.setHosts([SERVER_ID]);
|
||||
release.resolve();
|
||||
expect(await reading).toBeUndefined();
|
||||
expect(await reader.readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
|
||||
await reader.flush();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"uses the same certification for accepted and persisted rows (provenance=%s)",
|
||||
async (provenance) => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
const items = ["block-1", "block-2"].map((id) => {
|
||||
const item = timelineItem("Repeated markdown");
|
||||
item.id = id;
|
||||
if (item.kind === "assistant_message") item.messageId = "same-message";
|
||||
item.source = provenance ? { startSeq: 12, chunks: [{ seq: 12, offset: 0 }] } : undefined;
|
||||
return item;
|
||||
});
|
||||
const value = { ...timeline(), items };
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", value);
|
||||
const accepted = await cache.readTimeline(SERVER_ID, "agent-1");
|
||||
expect(accepted?.range).toEqual(provenance ? value.range : null);
|
||||
expect(accepted?.items).toEqual(items);
|
||||
await cache.flush();
|
||||
const persisted = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
|
||||
expect(persisted).toEqual(accepted);
|
||||
},
|
||||
);
|
||||
|
||||
it("serializes only the coalesced certified page, including reads of accepted commits", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
let encodings = 0;
|
||||
class MeasuredDate extends Date {
|
||||
override toISOString(): string {
|
||||
encodings += 1;
|
||||
return super.toISOString();
|
||||
}
|
||||
}
|
||||
for (let seq = 1; seq <= 10; seq++) {
|
||||
const item = {
|
||||
...timelineItem(`row ${seq}`),
|
||||
timestamp: new MeasuredDate("2026-07-18T08:02:00.000Z"),
|
||||
timelineCursor: { epoch: "epoch", seq },
|
||||
source: { startSeq: seq, chunks: [{ seq, offset: 0 }] },
|
||||
};
|
||||
cache.commitTimeline(
|
||||
SERVER_ID,
|
||||
"agent-1",
|
||||
{
|
||||
agentId: "agent-1",
|
||||
items: [item],
|
||||
range: { epoch: "epoch", startSeq: seq, endSeq: seq },
|
||||
hasOlder: true,
|
||||
},
|
||||
true,
|
||||
);
|
||||
expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([item]);
|
||||
}
|
||||
expect(encodings).toBe(0);
|
||||
await cache.flush();
|
||||
expect(encodings).toBe(1);
|
||||
expect((await createCache(storage).readTimeline(SERVER_ID, "agent-1"))?.range?.endSeq).toBe(10);
|
||||
});
|
||||
|
||||
it("does not resurrect a removed host when its in-flight write completes", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
const release = deferred();
|
||||
storage.writeGate = release.promise;
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Removed history"));
|
||||
const writing = cache.flush();
|
||||
await expect.poll(() => storage.writes).toBe(1);
|
||||
cache.setHosts([]);
|
||||
release.resolve();
|
||||
await writing;
|
||||
cache.setHosts([SERVER_ID]);
|
||||
await cache.flush();
|
||||
expect(await cache.readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
|
||||
expect(storage.rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps rows written under a host renamed during their write", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
const release = deferred();
|
||||
storage.writeGate = release.promise;
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Renamed while writing"));
|
||||
const writing = cache.flush();
|
||||
await expect.poll(() => storage.writes).toBe(1);
|
||||
cache.reconcileServerId(SERVER_ID, "resolved-host");
|
||||
release.resolve();
|
||||
await writing;
|
||||
await cache.flush();
|
||||
|
||||
expect((await cache.readTimeline("resolved-host", "agent-1"))?.items).toEqual([
|
||||
timelineItem("Renamed while writing"),
|
||||
]);
|
||||
expect(await cache.readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
|
||||
expect([...storage.rows.keys()]).toEqual(["resolved-host:timeline:agent-1"]);
|
||||
});
|
||||
|
||||
it("writes a row replaced during its own write again", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
const release = deferred();
|
||||
storage.writeGate = release.promise;
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("First"));
|
||||
const writing = cache.flush();
|
||||
await expect.poll(() => storage.writes).toBe(1);
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Second"));
|
||||
release.resolve();
|
||||
await writing;
|
||||
await cache.flush();
|
||||
|
||||
expect(storage.writes).toBe(2);
|
||||
expect((await createCache(storage).readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([
|
||||
timelineItem("Second"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores a timeline while another host's disk write is blocked", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.setHosts([SERVER_ID, "other-host"]);
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Saved before restart"));
|
||||
await cache.flush();
|
||||
const release = deferred();
|
||||
storage.writeGate = release.promise;
|
||||
commitDirectory(cache, "other-host", directory());
|
||||
const writing = cache.flush();
|
||||
await expect.poll(() => storage.writes).toBe(2);
|
||||
let restored: Awaited<ReturnType<ReplicaCache["readTimeline"]>>;
|
||||
const reading = cache.readTimeline(SERVER_ID, "agent-1").then((value) => {
|
||||
restored = value;
|
||||
return value;
|
||||
});
|
||||
try {
|
||||
await expect
|
||||
.poll(() => restored?.items, { timeout: 200 })
|
||||
.toEqual([timelineItem("Saved before restart")]);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await Promise.all([writing, reading]);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads rows under a resolved host identity before the disk rename finishes", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Saved under temporary host ID"));
|
||||
await writer.flush();
|
||||
const reader = createCache(storage);
|
||||
const release = deferred();
|
||||
storage.renameGate = release.promise;
|
||||
reader.reconcileServerId(SERVER_ID, "resolved-host");
|
||||
expect((await reader.readTimeline("resolved-host", "agent-1"))?.items).toEqual([
|
||||
timelineItem("Saved under temporary host ID"),
|
||||
]);
|
||||
release.resolve();
|
||||
await reader.flush();
|
||||
expect(storage.rows.has("resolved-host:timeline:agent-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps rename collision precedence and accepted baselines during lazy reads", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
writer.setHosts([SERVER_ID, "resolved-host"]);
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Moved"));
|
||||
writer.commitTimeline("resolved-host", "agent-1", timeline("Superseded target"));
|
||||
commitDirectory(writer, SERVER_ID, directory());
|
||||
await writer.flush();
|
||||
const reader = createCache(storage);
|
||||
reader.setHosts([SERVER_ID, "resolved-host"]);
|
||||
const release = deferred();
|
||||
storage.renameGate = release.promise;
|
||||
reader.replaceDirectoryBaseline(SERVER_ID, {
|
||||
...directory(),
|
||||
workspaces: new Map(),
|
||||
projects: new Map(),
|
||||
});
|
||||
reader.reconcileServerId(SERVER_ID, "resolved-host");
|
||||
try {
|
||||
expect((await reader.readTimeline("resolved-host", "agent-1"))?.items).toEqual([
|
||||
timelineItem("Moved"),
|
||||
]);
|
||||
const accepted = await reader.readDirectory("resolved-host");
|
||||
expect(accepted.agents.get("agent-1")?.serverId).toBe("resolved-host");
|
||||
expect(accepted.workspaces.size).toBe(0);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await reader.flush();
|
||||
}
|
||||
const reopened = new ReplicaCache(storage, noLegacyCleanup);
|
||||
reopened.setHosts(["resolved-host"]);
|
||||
expect((await reopened.readDirectory("resolved-host")).workspaces.size).toBe(0);
|
||||
expect((await reopened.readTimeline("resolved-host", "agent-1"))?.items).toEqual([
|
||||
timelineItem("Moved"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a committed timeline before it is written", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Accepted"));
|
||||
|
||||
expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([
|
||||
timelineItem("Accepted"),
|
||||
]);
|
||||
expect(storage.writes).toBe(0);
|
||||
});
|
||||
|
||||
it("round-trips a row's source start and chunk provenance", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", {
|
||||
...timeline(),
|
||||
items: [
|
||||
{
|
||||
...timelineItem("Streamed"),
|
||||
source: {
|
||||
startSeq: 10,
|
||||
chunks: [
|
||||
{ seq: 10, offset: 0 },
|
||||
{ seq: 12, offset: 4 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await cache.flush();
|
||||
|
||||
const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
|
||||
expect(restored?.items[0]).toMatchObject({ source: { startSeq: 10 } });
|
||||
expect(restored?.items[0]?.source?.chunks).toEqual([
|
||||
{ seq: 10, offset: 0 },
|
||||
{ seq: 12, offset: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists the owner's forty-entry restart page from a long synced history", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
const items = Array.from({ length: 40 }, (_, index) => ({
|
||||
...timelineItem(`Message ${index}`),
|
||||
id: `message-${index}`,
|
||||
timelineCursor: { epoch: "epoch-1", seq: index + 201 },
|
||||
source: { startSeq: index + 201, chunks: [{ seq: index + 201, offset: 0 }] },
|
||||
}));
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", {
|
||||
agentId: "agent-1",
|
||||
items,
|
||||
range: { epoch: "epoch-1", startSeq: 201, endSeq: 240 },
|
||||
hasOlder: true,
|
||||
});
|
||||
await cache.flush();
|
||||
|
||||
const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
|
||||
expect(restored?.items).toHaveLength(40);
|
||||
expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 201, endSeq: 240 });
|
||||
expect(restored?.hasOlder).toBe(true);
|
||||
});
|
||||
|
||||
it("encodes the owner's certified page without a per-timeline byte ceiling", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
const items = Array.from({ length: 40 }, (_, index) => ({
|
||||
...timelineItem("x".repeat(20_000)),
|
||||
id: `message-${index}`,
|
||||
timelineCursor: { epoch: "epoch-1", seq: index + 1 },
|
||||
source: { startSeq: index + 1, chunks: [{ seq: index + 1, offset: 0 }] },
|
||||
}));
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", {
|
||||
agentId: "agent-1",
|
||||
items,
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 40 },
|
||||
hasOlder: true,
|
||||
});
|
||||
await cache.flush();
|
||||
|
||||
const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
|
||||
expect(restored?.items.map((item) => item.id)).toEqual(items.map((item) => item.id));
|
||||
expect(restored?.range).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 40 });
|
||||
expect(restored?.hasOlder).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing until an owner explicitly commits data", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
@@ -787,10 +409,9 @@ describe("ReplicaCache", () => {
|
||||
|
||||
deleteDirectory(cache, SERVER_ID);
|
||||
|
||||
const restored = await cache.readDirectory(SERVER_ID);
|
||||
expect(restored.agents.size).toBe(0);
|
||||
expect(restored.workspaces.size).toBe(0);
|
||||
expect(restored.projects.size).toBe(0);
|
||||
expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined();
|
||||
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
|
||||
expect((await cache.readDirectory(SERVER_ID)).projects.size).toBe(0);
|
||||
});
|
||||
|
||||
it("fails closed when an accepted deletion cannot be persisted before a read", async () => {
|
||||
@@ -802,23 +423,25 @@ describe("ReplicaCache", () => {
|
||||
|
||||
deleteDirectory(cache, SERVER_ID);
|
||||
|
||||
expect((await cache.readDirectory(SERVER_ID)).workspaces.size).toBe(0);
|
||||
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies a deletion accepted while a scoped directory read is still loading", async () => {
|
||||
it("discards a durable read when the host changes while it is in flight", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
commitDirectory(writer, SERVER_ID, directory());
|
||||
await writer.flush();
|
||||
const release = deferred();
|
||||
storage.readGate = release.promise;
|
||||
const cache = createCache(storage);
|
||||
commitDirectory(cache, SERVER_ID, directory());
|
||||
await cache.flush();
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
storage.onRead = started.resolve;
|
||||
storage.readGate = release.promise;
|
||||
|
||||
const reading = cache.readDirectory(SERVER_ID);
|
||||
const reading = cache.readAgent(SERVER_ID, "agent-1");
|
||||
await started.promise;
|
||||
deleteDirectory(cache, SERVER_ID);
|
||||
release.resolve();
|
||||
|
||||
expect((await reading).agents.size).toBe(0);
|
||||
expect(await reading).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never reads a timeline older than an accepted deferred replacement", async () => {
|
||||
@@ -890,6 +513,39 @@ describe("ReplicaCache", () => {
|
||||
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads one requested agent and the focused timeline without scanning directory rows", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
commitDirectory(writer, SERVER_ID, directory());
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline());
|
||||
await writer.flush();
|
||||
|
||||
const reader = createCache(storage);
|
||||
expect((await reader.readAgent(SERVER_ID, "agent-1"))?.id).toBe("agent-1");
|
||||
expect((await reader.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([timelineItem()]);
|
||||
expect(storage.reads).toEqual([
|
||||
{ serverId: SERVER_ID, kinds: ["agent"], ids: ["agent-1"] },
|
||||
{ serverId: SERVER_ID, kinds: ["timeline"], ids: ["agent-1"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads one requested workspace and its project without scanning the directory", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
commitDirectory(writer, SERVER_ID, directory());
|
||||
await writer.flush();
|
||||
|
||||
const reader = createCache(storage);
|
||||
const restored = await reader.readWorkspace(SERVER_ID, "workspace-1");
|
||||
|
||||
expect(restored?.workspace.id).toBe("workspace-1");
|
||||
expect(restored?.project?.projectId).toBe("project-1");
|
||||
expect(storage.reads).toEqual([
|
||||
{ serverId: SERVER_ID, kinds: ["workspace"], ids: ["workspace-1"] },
|
||||
{ serverId: SERVER_ID, kinds: ["project"], ids: ["project-1"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a corrupt row as a scoped miss", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.rows.set(`${SERVER_ID}:agent:agent-1`, {
|
||||
@@ -914,7 +570,6 @@ describe("ReplicaCache", () => {
|
||||
const cache = createCache(storage);
|
||||
|
||||
const restored = await cache.readDirectory(SERVER_ID);
|
||||
await cache.flush();
|
||||
|
||||
expect(restored.agents.size).toBe(0);
|
||||
expect(restored.projects.get("project-1")?.projectDisplayName).toBe("Paseo");
|
||||
@@ -946,7 +601,7 @@ describe("ReplicaCache", () => {
|
||||
commitDirectory(cache, otherServerId, directory());
|
||||
await cache.flush();
|
||||
|
||||
expect((await cache.readDirectory(SERVER_ID)).agents.has("agent-1")).toBe(false);
|
||||
expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined();
|
||||
cache.commitTimeline(otherServerId, "agent-1", timeline("x".repeat(1_000)));
|
||||
await cache.flush();
|
||||
|
||||
@@ -972,9 +627,7 @@ describe("ReplicaCache", () => {
|
||||
payload: "{bad",
|
||||
});
|
||||
|
||||
const cache = createCache(storage);
|
||||
expect((await cache.readDirectory(SERVER_ID)).agents.has("agent-1")).toBe(false);
|
||||
await cache.flush();
|
||||
expect(await createCache(storage).readAgent(SERVER_ID, "agent-1")).toBeUndefined();
|
||||
|
||||
expect(storage.changes.at(-1)).toMatchObject({
|
||||
deletes: [{ serverId: SERVER_ID, kind: "agent", id: "agent-1" }],
|
||||
@@ -1004,9 +657,7 @@ describe("ReplicaCache", () => {
|
||||
payload: "{bad",
|
||||
});
|
||||
|
||||
const cache = createCache(storage);
|
||||
const restored = await cache.readDirectory(SERVER_ID);
|
||||
await cache.flush();
|
||||
const restored = await createCache(storage).readDirectory(SERVER_ID);
|
||||
|
||||
expect(restored.checkpoint).toEqual({
|
||||
projects: { generation: "g", afterSeq: 4 },
|
||||
@@ -1037,66 +688,6 @@ describe("ReplicaCache", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["nextOpenFailure", "nextReadAllFailure"] as const)(
|
||||
"persists an accepted commit automatically after %s",
|
||||
async (failure) => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
storage[failure] = new Error("storage busy");
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Retry me"));
|
||||
|
||||
await cache.flush();
|
||||
expect(storage.rows.size).toBe(0);
|
||||
expect(await cache.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Retry me"));
|
||||
await expect.poll(() => storage.rows.size, { timeout: 2500 }).toBe(1);
|
||||
|
||||
const reopened = createCache(storage);
|
||||
expect(await reopened.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Retry me"));
|
||||
},
|
||||
);
|
||||
|
||||
it("recovers a partial index load with queued host changes and the exact surviving budget", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
writer.setHosts([SERVER_ID, "stale", "old", "removed"]);
|
||||
for (const host of [SERVER_ID, "stale", "old", "removed"]) {
|
||||
writer.commitTimeline(host, "agent-1", timeline("Stored"));
|
||||
}
|
||||
await writer.flush();
|
||||
const survivorBytes = [...storage.rows.values()]
|
||||
.filter((row) => row.serverId === SERVER_ID || row.serverId === "old")
|
||||
.reduce((bytes, row) => bytes + Buffer.byteLength(row.payload), 0);
|
||||
const cache = createCache(storage, survivorBytes);
|
||||
cache.setHosts([SERVER_ID, "old", "removed"]);
|
||||
const release = deferred();
|
||||
storage.readAllGate = release.promise;
|
||||
storage.nextDeleteFailure = new Error("stale host cleanup busy");
|
||||
const loadsBefore = storage.readAlls;
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Newest"));
|
||||
const writing = cache.flush();
|
||||
try {
|
||||
await expect.poll(() => storage.readAlls).toBe(loadsBefore + 1);
|
||||
cache.reconcileServerId("old", "renamed");
|
||||
cache.setHosts([SERVER_ID, "renamed"]);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await writing;
|
||||
}
|
||||
expect(await cache.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Newest"));
|
||||
expect(await cache.readTimeline("renamed", "agent-1")).toEqual(timeline("Stored"));
|
||||
expect(await cache.readTimeline("removed", "agent-1")).toBeUndefined();
|
||||
await expect.poll(() => storage.changes.length, { timeout: 2500 }).toBe(2);
|
||||
|
||||
const reopened = createCache(storage);
|
||||
reopened.setHosts([SERVER_ID, "renamed"]);
|
||||
expect([...storage.rows.values()].map((row) => row.serverId).sort()).toEqual([
|
||||
SERVER_ID,
|
||||
"renamed",
|
||||
]);
|
||||
expect(await reopened.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline("Newest"));
|
||||
expect(await reopened.readTimeline("renamed", "agent-1")).toEqual(timeline("Stored"));
|
||||
});
|
||||
|
||||
it("retries an explicit commit after a storage failure", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
@@ -1112,6 +703,21 @@ describe("ReplicaCache", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("retries a timeline read invalidated by a concurrent directory commit", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Persisted timeline"));
|
||||
await cache.flush();
|
||||
storage.onRead = () => {
|
||||
storage.onRead = null;
|
||||
commitDirectory(cache, SERVER_ID, directory());
|
||||
};
|
||||
|
||||
expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([
|
||||
timelineItem("Persisted timeline"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rebuilds every directory row before restoring its checkpoint after eviction", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 });
|
||||
@@ -1153,7 +759,7 @@ describe("ReplicaCache", () => {
|
||||
});
|
||||
cache.setHosts([SERVER_ID]);
|
||||
|
||||
await cache.readTimeline(SERVER_ID, "missing");
|
||||
await cache.readAgent(SERVER_ID, "missing");
|
||||
commitDirectory(cache, SERVER_ID, directory());
|
||||
await cache.flush();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -108,17 +108,20 @@ export function createSqliteReplicaRowStore(
|
||||
}
|
||||
|
||||
async function read(
|
||||
serverIds: readonly string[],
|
||||
serverId: string,
|
||||
kinds: readonly ReplicaRow["kind"][],
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
if (!serverIds.length || !kinds.length || ids?.length === 0) return [];
|
||||
const placeholders = (values: readonly string[]) => values.map(() => "?").join(",");
|
||||
const rows = await getConnection().all<StoredRow>(
|
||||
`SELECT server_id, kind, id, payload FROM rows WHERE server_id IN (${placeholders(serverIds)}) AND kind IN (${placeholders(kinds)})${ids ? ` AND id IN (${placeholders(ids)})` : ""} ORDER BY server_id, kind, id`,
|
||||
[...serverIds, ...kinds, ...(ids ?? [])],
|
||||
if (kinds.length === 0 || ids?.length === 0) return [];
|
||||
const placeholders = kinds.map(() => "?").join(", ");
|
||||
const idClause = ids ? ` AND id IN (${ids.map(() => "?").join(", ")})` : "";
|
||||
const storedRows = await getConnection().all<StoredRow>(
|
||||
`SELECT server_id, kind, id, payload FROM rows
|
||||
WHERE server_id = ? AND kind IN (${placeholders})${idClause}
|
||||
ORDER BY kind, id`,
|
||||
[serverId, ...kinds, ...(ids ?? [])],
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
return storedRows.map((row) => ({
|
||||
serverId: row.server_id,
|
||||
kind: row.kind,
|
||||
id: row.id,
|
||||
|
||||
@@ -55,23 +55,20 @@ export function runReplicaRowStoreContract(
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads only requested hosts, kinds and identities", async () => {
|
||||
const selected = row("server-a", "timeline", "wanted", "page");
|
||||
it("reads only requested kinds for one host", async () => {
|
||||
await store.apply({
|
||||
deletes: [],
|
||||
upserts: [
|
||||
selected,
|
||||
row("server-a", "timeline", "other", "unrelated timeline"),
|
||||
row("server-a", "agent", "wanted", "directory"),
|
||||
row("server-b", "timeline", "wanted", "other host"),
|
||||
row("server-a", "agent", "agent-1", "agent"),
|
||||
row("server-a", "timeline", "singleton", "timeline"),
|
||||
row("server-b", "agent", "agent-2", "other host"),
|
||||
],
|
||||
deletes: [],
|
||||
});
|
||||
expect(await store.read(["server-a"], ["timeline"], ["wanted"])).toEqual([selected]);
|
||||
expect(await store.read(["server-a"], ["agent"])).toEqual([
|
||||
row("server-a", "agent", "wanted", "directory"),
|
||||
|
||||
expect(await store.read("server-a", ["timeline"])).toEqual([
|
||||
row("server-a", "timeline", "singleton", "timeline"),
|
||||
]);
|
||||
expect(await store.read([], ["timeline"])).toEqual([]);
|
||||
expect(await store.read(["server-a"], ["timeline"], [])).toEqual([]);
|
||||
expect(await store.read("server-a", ["agent"], ["missing"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("overwrites an existing row on upsert", async () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface ReplicaHostRows {
|
||||
export interface ReplicaRowStore {
|
||||
open(): Promise<void>;
|
||||
read(
|
||||
serverIds: readonly string[],
|
||||
serverId: string,
|
||||
kinds: readonly ReplicaRowKind[],
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]>;
|
||||
|
||||
@@ -24,41 +24,28 @@ runReplicaRowStoreContract("IndexedDB", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
it("reads requested IndexedDB keys without scanning unrelated rows", async () => {
|
||||
const options = {
|
||||
it("uses exact IndexedDB keys for targeted rows instead of scanning the host", async () => {
|
||||
const store = createIndexedDbReplicaRowStore({
|
||||
databaseName: `replica-row-store-targeted-${databaseSequence++}`,
|
||||
schemaVersion: 1,
|
||||
};
|
||||
const writer = createIndexedDbReplicaRowStore(options);
|
||||
await writer.open();
|
||||
await writer.apply({
|
||||
});
|
||||
await store.open();
|
||||
await store.apply({
|
||||
upserts: [
|
||||
{ serverId: "host-a", kind: "timeline", id: "agent", payload: "a" },
|
||||
{ serverId: "host-b", kind: "timeline", id: "agent", payload: "b" },
|
||||
{ serverId: "host-a", kind: "timeline", id: "unrelated", payload: "large history" },
|
||||
{ serverId: "host-a", kind: "agent", id: "agent", payload: "metadata" },
|
||||
{ serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" },
|
||||
{ serverId: "server-a", kind: "workspace", id: "workspace-1", payload: "workspace" },
|
||||
],
|
||||
deletes: [],
|
||||
});
|
||||
const reader = createIndexedDbReplicaRowStore(options);
|
||||
await reader.open();
|
||||
const get = vi.spyOn(FakeIDBObjectStore.prototype, "get");
|
||||
const getAll = vi.spyOn(FakeIDBObjectStore.prototype, "getAll");
|
||||
const openCursor = vi.spyOn(FakeIDBObjectStore.prototype, "openCursor");
|
||||
try {
|
||||
expect(await reader.read(["host-a", "host-b"], ["timeline"], ["agent"])).toEqual([
|
||||
{ serverId: "host-a", kind: "timeline", id: "agent", payload: "a" },
|
||||
{ serverId: "host-b", kind: "timeline", id: "agent", payload: "b" },
|
||||
]);
|
||||
expect(get.mock.calls).toEqual([
|
||||
[["host-a", "timeline", "agent"]],
|
||||
[["host-b", "timeline", "agent"]],
|
||||
]);
|
||||
expect(getAll).not.toHaveBeenCalled();
|
||||
expect(openCursor).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
get.mockRestore();
|
||||
getAll.mockRestore();
|
||||
openCursor.mockRestore();
|
||||
}
|
||||
|
||||
expect(await store.read("server-a", ["agent"], ["agent-1"])).toEqual([
|
||||
{ serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" },
|
||||
]);
|
||||
expect(get).toHaveBeenCalledWith(["server-a", "agent", "agent-1"]);
|
||||
expect(getAll).not.toHaveBeenCalled();
|
||||
|
||||
get.mockRestore();
|
||||
getAll.mockRestore();
|
||||
});
|
||||
|
||||
@@ -136,6 +136,38 @@ export function createIndexedDbReplicaRowStore(
|
||||
return Array.from(hosts, ([serverId, hostRows]) => ({ serverId, rows: hostRows }));
|
||||
}
|
||||
|
||||
async function read(
|
||||
serverId: string,
|
||||
kinds: readonly ReplicaRow["kind"][],
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
if (kinds.length === 0 || ids?.length === 0) return [];
|
||||
const transaction = getDatabase().transaction(ROWS_STORE, "readonly");
|
||||
const completion = transactionComplete(transaction);
|
||||
const store = transaction.objectStore(ROWS_STORE);
|
||||
const requests = ids
|
||||
? kinds.flatMap((kind) =>
|
||||
ids.map(async (id) => {
|
||||
const row = await requestResult<ReplicaRow | undefined>(
|
||||
store.get([serverId, kind, id]),
|
||||
);
|
||||
return row ? [row] : [];
|
||||
}),
|
||||
)
|
||||
: kinds.map((kind) =>
|
||||
requestResult<ReplicaRow[]>(
|
||||
store.getAll(IDBKeyRange.bound([serverId, kind], [serverId, kind, []])),
|
||||
),
|
||||
);
|
||||
const rows = (await Promise.all(requests)).flat();
|
||||
await completion;
|
||||
return rows.sort((left, right) =>
|
||||
left.kind === right.kind
|
||||
? left.id.localeCompare(right.id)
|
||||
: left.kind.localeCompare(right.kind),
|
||||
);
|
||||
}
|
||||
|
||||
async function apply(changes: ReplicaRowChanges): Promise<void> {
|
||||
await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => {
|
||||
const rows = transaction.objectStore(ROWS_STORE);
|
||||
@@ -148,39 +180,6 @@ export function createIndexedDbReplicaRowStore(
|
||||
});
|
||||
}
|
||||
|
||||
async function read(
|
||||
serverIds: readonly string[],
|
||||
kinds: readonly ReplicaRow["kind"][],
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
const transaction = getDatabase().transaction(ROWS_STORE, "readonly");
|
||||
const completion = transactionComplete(transaction);
|
||||
const store = transaction.objectStore(ROWS_STORE);
|
||||
const requests: Promise<ReplicaRow[]>[] = [];
|
||||
for (const serverId of serverIds) {
|
||||
for (const kind of kinds) {
|
||||
if (ids) {
|
||||
for (const id of ids) {
|
||||
requests.push(
|
||||
requestResult<ReplicaRow | undefined>(store.get([serverId, kind, id])).then((row) =>
|
||||
row ? [row] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
requests.push(
|
||||
requestResult<ReplicaRow[]>(
|
||||
store.getAll(IDBKeyRange.bound([serverId, kind], [serverId, kind, []])),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const rows = (await Promise.all(requests)).flat();
|
||||
await completion;
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function deleteHost(serverId: string): Promise<void> {
|
||||
await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => {
|
||||
const rows = transaction.objectStore(ROWS_STORE);
|
||||
|
||||
@@ -61,7 +61,6 @@ import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"
|
||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import { traceInstant } from "@/performance/native-trace";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { ViewedTimelineDemandDeclaration } from "@/timeline/viewed-timeline-sync";
|
||||
import {
|
||||
canDismissPaneInLayout,
|
||||
collectAllTabs,
|
||||
@@ -1547,6 +1546,12 @@ function WorkspaceScreenContent({
|
||||
[workspaceId],
|
||||
);
|
||||
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
|
||||
useEffect(() => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return;
|
||||
void getHostRuntimeStore()
|
||||
.prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId)
|
||||
.catch(() => undefined);
|
||||
}, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]);
|
||||
const workspaceScripts = getWorkspaceScripts(workspaceDescriptor);
|
||||
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
|
||||
useWorkspaceRouteActions(normalizedServerId);
|
||||
@@ -1909,23 +1914,25 @@ function WorkspaceScreenContent({
|
||||
routeFocused: isRouteFocused,
|
||||
focusedPaneOnly: syncFocusedPaneOnly,
|
||||
});
|
||||
const visibleTimelineDeclarationRef = useRef<ViewedTimelineDemandDeclaration | null>(null);
|
||||
useEffect(() => {
|
||||
for (const agentId of visibleAgentIds) {
|
||||
void getHostRuntimeStore()
|
||||
.prepareAgentTimeline(normalizedServerId, agentId)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}, [normalizedServerId, visibleAgentIds]);
|
||||
useLayoutEffect(() => {
|
||||
if (!persistenceKey || !viewedTimelineSync) {
|
||||
return;
|
||||
}
|
||||
const declaration = viewedTimelineSync.registerVisibleAgentIds(persistenceKey, []);
|
||||
visibleTimelineDeclarationRef.current = declaration;
|
||||
return () => {
|
||||
if (visibleTimelineDeclarationRef.current === declaration) {
|
||||
visibleTimelineDeclarationRef.current = null;
|
||||
}
|
||||
declaration.dispose();
|
||||
};
|
||||
}, [persistenceKey, viewedTimelineSync]);
|
||||
useLayoutEffect(() => {
|
||||
visibleTimelineDeclarationRef.current?.replace(visibleAgentIds);
|
||||
viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, visibleAgentIds);
|
||||
}, [persistenceKey, viewedTimelineSync, visibleAgentIds]);
|
||||
useEffect(() => {
|
||||
if (!persistenceKey || !viewedTimelineSync) {
|
||||
return;
|
||||
}
|
||||
return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []);
|
||||
}, [persistenceKey, viewedTimelineSync]);
|
||||
const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId);
|
||||
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
|
||||
const focusedPaneAgentId = useMemo(() => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createTimelineReplica, type TimelineReplica } from "@/timeline/replica";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -86,37 +85,8 @@ afterEach(() => {
|
||||
useSessionStore.getState().clearSession("test-server");
|
||||
});
|
||||
|
||||
let timelineReplica: TimelineReplica;
|
||||
|
||||
function initializeTestSession(): void {
|
||||
useSessionStore.getState().initializeSession("test-server", null as unknown as DaemonClient);
|
||||
timelineReplica = createTimelineReplica({
|
||||
serverId: "test-server",
|
||||
storage: {
|
||||
removeTimeline: () => undefined,
|
||||
readTimeline: async () => undefined,
|
||||
commitTimeline: () => undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function acknowledgeSubmission(agentId: string, clientMessageId: string): void {
|
||||
timelineReplica.applyEvents(
|
||||
agentId,
|
||||
[
|
||||
{
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
timestamp: new Date("2026-07-31T10:00:01.000Z"),
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text: clientMessageId, messageId: clientMessageId },
|
||||
},
|
||||
},
|
||||
],
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function getTestSessionReferences() {
|
||||
@@ -171,38 +141,16 @@ describe("agent task state", () => {
|
||||
);
|
||||
|
||||
const tasks = [{ id: "1", text: "Inspect", status: "pending" as const, completed: false }];
|
||||
const applyTasks = (items: typeof tasks) =>
|
||||
timelineReplica.applyEvents(
|
||||
"agent-1",
|
||||
[
|
||||
{
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
timestamp: new Date("2026-08-11T10:00:00.000Z"),
|
||||
event: { type: "timeline", provider: "claude", item: { type: "todo", items } },
|
||||
},
|
||||
],
|
||||
() => undefined,
|
||||
);
|
||||
applyTasks(tasks);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentStreamState("test-server", "agent-1", { taskSnapshot: tasks });
|
||||
useSessionStore.getState().setIsPlayingAudio("test-server", true);
|
||||
applyTasks([...tasks]);
|
||||
timelineReplica.applyEvents(
|
||||
"agent-1",
|
||||
[
|
||||
{
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
timestamp: new Date("2026-08-11T10:00:01.000Z"),
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "todo", items: [{ ...tasks[0], status: "completed", completed: true }] },
|
||||
},
|
||||
},
|
||||
],
|
||||
() => undefined,
|
||||
);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentStreamState("test-server", "agent-1", { taskSnapshot: [...tasks] });
|
||||
useSessionStore.getState().setAgentStreamState("test-server", "agent-1", {
|
||||
taskSnapshot: [{ ...tasks[0], status: "completed", completed: true }],
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
expect(snapshots).toEqual([["Inspect"], ["Inspect"]]);
|
||||
@@ -226,7 +174,7 @@ describe("agent task state", () => {
|
||||
older: "none",
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
submissions: [],
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
expect(useSessionStore.getState().sessions["test-server"]?.agentTasks.get("agent-1")).toEqual(
|
||||
@@ -236,107 +184,6 @@ describe("agent task state", () => {
|
||||
});
|
||||
|
||||
describe("agent timeline state", () => {
|
||||
it("preserves unchanged projection maps and only rescans tasks when rows change", () => {
|
||||
initializeTestSession();
|
||||
const store = useSessionStore.getState();
|
||||
let scans = 0;
|
||||
const item: StreamItem = {
|
||||
get kind() {
|
||||
scans++;
|
||||
return "assistant_message" as const;
|
||||
},
|
||||
id: "row",
|
||||
text: "text",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
const input = {
|
||||
items: [item],
|
||||
head: [],
|
||||
range: { epoch: "epoch", startSeq: 1, endSeq: 1 },
|
||||
older: "available" as const,
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
submissions: [],
|
||||
};
|
||||
store.applyAgentTimelineResponseState("test-server", "agent-1", input);
|
||||
const before = useSessionStore.getState();
|
||||
const session = before.sessions["test-server"]!;
|
||||
const scanned = scans;
|
||||
let notices = 0;
|
||||
const unsubscribe = useSessionStore.subscribe(() => notices++);
|
||||
store.applyAgentTimelineResponseState("test-server", "agent-1", {
|
||||
...input,
|
||||
head: [],
|
||||
range: { ...input.range },
|
||||
submissions: [],
|
||||
});
|
||||
expect(useSessionStore.getState()).toBe(before);
|
||||
expect(notices).toBe(0);
|
||||
store.applyAgentTimelineResponseState("test-server", "agent-1", { ...input, newer: true });
|
||||
const changed = useSessionStore.getState().sessions["test-server"]!;
|
||||
expect(changed.agentTimelineHasNewer).not.toBe(session.agentTimelineHasNewer);
|
||||
for (const key of [
|
||||
"agentStreamTail",
|
||||
"agentStreamHead",
|
||||
"agentTimelineCursor",
|
||||
"agentTimelineHasOlder",
|
||||
"agentAuthoritativeHistoryApplied",
|
||||
"agentHistorySyncGeneration",
|
||||
"messageSubmissions",
|
||||
"agentTasks",
|
||||
] as const)
|
||||
expect(changed[key]).toBe(session[key]);
|
||||
expect(notices).toBe(1);
|
||||
expect(scans).toBe(scanned);
|
||||
store.bumpHistorySyncGeneration("test-server");
|
||||
const stale = useSessionStore.getState().sessions["test-server"]!;
|
||||
store.applyAgentTimelineResponseState("test-server", "agent-1", { ...input, newer: true });
|
||||
const synced = useSessionStore.getState().sessions["test-server"]!;
|
||||
expect(synced.agentHistorySyncGeneration.get("agent-1")).toBe(synced.historySyncGeneration);
|
||||
expect(synced.agentHistorySyncGeneration).not.toBe(stale.agentHistorySyncGeneration);
|
||||
expect(synced.agentStreamTail).toBe(stale.agentStreamTail);
|
||||
expect(scans).toBe(scanned);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("settles submissions and deletes optional entries while retaining an empty authoritative tail", () => {
|
||||
initializeTestSession();
|
||||
const store = useSessionStore.getState();
|
||||
const input = {
|
||||
items: [],
|
||||
head: [],
|
||||
range: null,
|
||||
older: "none" as const,
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
submissions: [{ clientMessageId: "local", providerAcknowledged: false, rpcSettled: false }],
|
||||
};
|
||||
store.applyAgentTimelineResponseState("test-server", "agent-1", input);
|
||||
const pending = useSessionStore.getState().sessions["test-server"]!;
|
||||
store.applyAgentTimelineResponseState("test-server", "agent-1", {
|
||||
...input,
|
||||
submissions: [{ ...input.submissions[0]!, rpcSettled: true }],
|
||||
});
|
||||
const settled = useSessionStore.getState().sessions["test-server"]!;
|
||||
expect(settled.messageSubmissions.get("agent-1")?.[0]?.rpcSettled).toBe(true);
|
||||
expect(settled.agentStreamTail).toBe(pending.agentStreamTail);
|
||||
store.applyAgentTimelineResponseState("test-server", "agent-1", { ...input, submissions: [] });
|
||||
const empty = useSessionStore.getState();
|
||||
const session = empty.sessions["test-server"]!;
|
||||
expect(session.agentStreamTail.has("agent-1")).toBe(true);
|
||||
expect(session.agentStreamHead.has("agent-1")).toBe(false);
|
||||
expect(session.agentTimelineCursor.has("agent-1")).toBe(false);
|
||||
expect(session.messageSubmissions.has("agent-1")).toBe(false);
|
||||
expect(session.agentTasks.has("agent-1")).toBe(false);
|
||||
store.applyAgentTimelineResponseState("test-server", "agent-1", {
|
||||
...input,
|
||||
items: [],
|
||||
head: [],
|
||||
submissions: [],
|
||||
});
|
||||
expect(useSessionStore.getState()).toBe(empty);
|
||||
});
|
||||
|
||||
it("commits canonical items, range, and older availability as one synced state", () => {
|
||||
initializeTestSession();
|
||||
const items: StreamItem[] = [
|
||||
@@ -355,7 +202,7 @@ describe("agent timeline state", () => {
|
||||
older: "available",
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
submissions: [],
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -378,7 +225,7 @@ describe("agent timeline state", () => {
|
||||
older: "none",
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
submissions: [],
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -396,7 +243,7 @@ describe("agent timeline state", () => {
|
||||
older: "available",
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
submissions: [],
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
store.applyAgentTimelineResponseState("test-server", "agent-1", {
|
||||
@@ -406,7 +253,7 @@ describe("agent timeline state", () => {
|
||||
older: "unchanged",
|
||||
newer: false,
|
||||
synchronized: false,
|
||||
submissions: [],
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -459,12 +306,15 @@ describe("message submission ordering", () => {
|
||||
] as const;
|
||||
|
||||
function applyStep(step: (typeof steps)[number]): void {
|
||||
const store = useSessionStore.getState();
|
||||
if (step === "accept") {
|
||||
timelineReplica.acceptSubmission(agentId, clientMessageId);
|
||||
store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId);
|
||||
return;
|
||||
}
|
||||
if (step === "canonical-row") {
|
||||
acknowledgeSubmission(agentId, clientMessageId);
|
||||
store.setAgentStreamState("test-server", agentId, {
|
||||
acknowledgedClientMessageIds: [clientMessageId],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (step === "stream-open") {
|
||||
@@ -501,7 +351,9 @@ describe("message submission ordering", () => {
|
||||
for (const ordering of permutations(steps)) {
|
||||
useSessionStore.getState().clearSession("test-server");
|
||||
initializeTestSession();
|
||||
timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId));
|
||||
let canonicalObserved = false;
|
||||
|
||||
for (const step of ordering) {
|
||||
@@ -516,8 +368,11 @@ describe("message submission ordering", () => {
|
||||
}
|
||||
}
|
||||
|
||||
timelineReplica.acceptSubmission(agentId, clientMessageId);
|
||||
acknowledgeSubmission(agentId, clientMessageId);
|
||||
const store = useSessionStore.getState();
|
||||
store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId);
|
||||
store.setAgentStreamState("test-server", agentId, {
|
||||
acknowledgedClientMessageIds: [clientMessageId],
|
||||
});
|
||||
applyTestTurn("test-server", agentId, [
|
||||
{ type: "stream_close", turnId: "turn-1" },
|
||||
{ type: "snapshot", activeTurn: null },
|
||||
@@ -547,7 +402,9 @@ describe("message submission ordering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId));
|
||||
unsubscribe();
|
||||
|
||||
expect(notifications).toEqual([{ hasOptimisticRow: true, isActive: true }]);
|
||||
@@ -555,10 +412,13 @@ describe("message submission ordering", () => {
|
||||
|
||||
it("keeps another unacknowledged submission active after one row is acknowledged", () => {
|
||||
initializeTestSession();
|
||||
timelineReplica.beginSubmission(agentId, submittedMessage(clientMessageId), true);
|
||||
timelineReplica.beginSubmission(agentId, submittedMessage("client-2"), true);
|
||||
timelineReplica.acceptSubmission(agentId, clientMessageId);
|
||||
acknowledgeSubmission(agentId, clientMessageId);
|
||||
const store = useSessionStore.getState();
|
||||
store.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId));
|
||||
store.beginAgentMessageSubmission("test-server", agentId, submittedMessage("client-2"));
|
||||
store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId);
|
||||
store.setAgentStreamState("test-server", agentId, {
|
||||
acknowledgedClientMessageIds: [clientMessageId],
|
||||
});
|
||||
|
||||
expect(
|
||||
selectAgentTurnPresentation(useSessionStore.getState().sessions["test-server"], agentId)
|
||||
@@ -570,7 +430,9 @@ describe("message submission ordering", () => {
|
||||
initializeTestSession();
|
||||
const message = submittedMessage(clientMessageId);
|
||||
|
||||
const handedOff = timelineReplica.handoffSubmission(agentId, message);
|
||||
const handedOff = useSessionStore
|
||||
.getState()
|
||||
.handoffCreatedAgentUserMessage("test-server", agentId, message);
|
||||
|
||||
const session = useSessionStore.getState().sessions["test-server"];
|
||||
expect({
|
||||
|
||||
@@ -4,10 +4,22 @@ import { subscribeWithSelector } from "zustand/middleware";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import { type StreamItem, type TodoEntry } from "@/types/stream";
|
||||
import {
|
||||
appendSubmittedUserMessage,
|
||||
handoffCreatedAgentUserMessageToStream,
|
||||
removeSubmittedUserMessage,
|
||||
type StreamItem,
|
||||
type TodoEntry,
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import {
|
||||
acceptMessageSubmission,
|
||||
beginMessageSubmission,
|
||||
getActiveMessageSubmissions,
|
||||
observeMessageSubmissionCanonical,
|
||||
rejectMessageSubmission,
|
||||
type MessageSubmissionRecord,
|
||||
type MessageSubmissionRejectionOutcome,
|
||||
} from "@/composer/submission/model";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
@@ -284,18 +296,6 @@ export interface AgentTimelineCursorState {
|
||||
}>;
|
||||
}
|
||||
|
||||
function sameTimelineRange(
|
||||
current: AgentTimelineCursorState | undefined,
|
||||
next: AgentTimelineCursorState | null,
|
||||
): boolean {
|
||||
return (
|
||||
current?.epoch === next?.epoch &&
|
||||
current?.startSeq === next?.startSeq &&
|
||||
current?.endSeq === next?.endSeq &&
|
||||
current?.retainedRanges === next?.retainedRanges
|
||||
);
|
||||
}
|
||||
|
||||
export type AgentTimelineState =
|
||||
| { status: "cold" }
|
||||
| { status: "painted"; items: StreamItem[] }
|
||||
@@ -453,13 +453,74 @@ interface SessionStoreActions {
|
||||
setFocusedTerminalId: (serverId: string, terminalId: string | null) => void;
|
||||
|
||||
// Stream state (head/tail model)
|
||||
|
||||
setAgentStreamTail: (
|
||||
serverId: string,
|
||||
state:
|
||||
| Map<string, StreamItem[]>
|
||||
| ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>),
|
||||
) => void;
|
||||
setAgentStreamHead: (
|
||||
serverId: string,
|
||||
state:
|
||||
| Map<string, StreamItem[]>
|
||||
| ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>),
|
||||
) => void;
|
||||
setAgentStreamState: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
state: {
|
||||
tail?: StreamItem[];
|
||||
head?: StreamItem[];
|
||||
acknowledgedClientMessageIds?: readonly string[];
|
||||
taskSnapshot?: TodoEntry[];
|
||||
},
|
||||
) => void;
|
||||
beginAgentMessageSubmission: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
message: UserMessageItem,
|
||||
) => void;
|
||||
acceptAgentMessageSubmission: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
clientMessageId: string,
|
||||
) => void;
|
||||
rejectAgentMessageSubmission: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
clientMessageId: string,
|
||||
) => MessageSubmissionRejectionOutcome;
|
||||
handoffCreatedAgentUserMessage: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
message: UserMessageItem,
|
||||
) => boolean;
|
||||
clearAgentStreamHead: (serverId: string, agentId: string) => void;
|
||||
setAgentTimelineCursor: (
|
||||
serverId: string,
|
||||
state:
|
||||
| Map<string, AgentTimelineCursorState>
|
||||
| ((prev: Map<string, AgentTimelineCursorState>) => Map<string, AgentTimelineCursorState>),
|
||||
) => void;
|
||||
setAgentTimelineHasOlder: (
|
||||
serverId: string,
|
||||
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
||||
) => void;
|
||||
setAgentTimelineHasNewer: (
|
||||
serverId: string,
|
||||
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
||||
) => void;
|
||||
setAgentTimelineOlderFetchInFlight: (
|
||||
serverId: string,
|
||||
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
||||
) => void;
|
||||
bumpHistorySyncGeneration: (serverId: string) => void;
|
||||
|
||||
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
|
||||
setAgentAuthoritativeHistoryApplied: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
applied: boolean,
|
||||
) => void;
|
||||
applyAgentTimelineResponseState: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
@@ -470,12 +531,10 @@ interface SessionStoreActions {
|
||||
older: "available" | "none" | "unchanged";
|
||||
newer: boolean;
|
||||
synchronized: boolean;
|
||||
submissions: MessageSubmissionRecord[];
|
||||
acknowledgedClientMessageIds: string[];
|
||||
},
|
||||
) => void;
|
||||
|
||||
removeAgentTimeline: (serverId: string, agentId: string) => void;
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (
|
||||
serverId: string,
|
||||
@@ -872,6 +931,354 @@ export const useSessionStore = create<SessionStore>()(
|
||||
},
|
||||
|
||||
// Stream state (head/tail model)
|
||||
setAgentStreamTail: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextState = typeof state === "function" ? state(session.agentStreamTail) : state;
|
||||
if (session.agentStreamTail === nextState) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentStreamTail: nextState },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentStreamHead: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextState = typeof state === "function" ? state(session.agentStreamHead) : state;
|
||||
if (session.agentStreamHead === nextState) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentStreamHead: nextState },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentStreamState: (serverId, agentId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
let nextTail = session.agentStreamTail;
|
||||
let nextHead = session.agentStreamHead;
|
||||
let changedTail = false;
|
||||
let changedHead = false;
|
||||
|
||||
if (state.tail !== undefined) {
|
||||
const existingTail = session.agentStreamTail.get(agentId);
|
||||
if (existingTail !== state.tail) {
|
||||
nextTail = new Map(session.agentStreamTail);
|
||||
nextTail.set(agentId, state.tail);
|
||||
changedTail = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.head !== undefined) {
|
||||
const existingHead = session.agentStreamHead.get(agentId);
|
||||
const shouldDeleteHead = state.head.length === 0;
|
||||
if (shouldDeleteHead) {
|
||||
if (session.agentStreamHead.has(agentId)) {
|
||||
nextHead = new Map(session.agentStreamHead);
|
||||
nextHead.delete(agentId);
|
||||
changedHead = true;
|
||||
}
|
||||
} else if (existingHead !== state.head) {
|
||||
nextHead = new Map(session.agentStreamHead);
|
||||
nextHead.set(agentId, state.head);
|
||||
changedHead = true;
|
||||
}
|
||||
}
|
||||
|
||||
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
||||
const observedSubmissions = observeMessageSubmissionCanonical(
|
||||
currentSubmissions,
|
||||
state.acknowledgedClientMessageIds ?? [],
|
||||
);
|
||||
const changedSubmissions = observedSubmissions !== currentSubmissions;
|
||||
const agentTasks = updateAgentTasks(session.agentTasks, agentId, state.taskSnapshot);
|
||||
const changedTasks = agentTasks !== session.agentTasks;
|
||||
|
||||
if (!changedTail && !changedHead && !changedSubmissions && !changedTasks) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
let messageSubmissions = session.messageSubmissions;
|
||||
if (changedSubmissions) {
|
||||
messageSubmissions = new Map(session.messageSubmissions);
|
||||
if (observedSubmissions.length > 0) {
|
||||
messageSubmissions.set(agentId, observedSubmissions);
|
||||
} else {
|
||||
messageSubmissions.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail: nextTail,
|
||||
agentStreamHead: nextHead,
|
||||
agentTasks,
|
||||
messageSubmissions,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
beginAgentMessageSubmission: (serverId, agentId, message) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
if (!message.clientMessageId) {
|
||||
throw new Error("Beginning a message submission requires client identity");
|
||||
}
|
||||
const currentTail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const currentHead = session.agentStreamHead.get(agentId) ?? [];
|
||||
const stream = appendSubmittedUserMessage({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
message,
|
||||
});
|
||||
const submissions = beginMessageSubmission(
|
||||
session.messageSubmissions.get(agentId) ?? [],
|
||||
{ clientMessageId: message.clientMessageId },
|
||||
);
|
||||
const messageSubmissions = new Map(session.messageSubmissions);
|
||||
messageSubmissions.set(agentId, submissions);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail:
|
||||
stream.tail === currentTail
|
||||
? session.agentStreamTail
|
||||
: new Map(session.agentStreamTail).set(agentId, stream.tail),
|
||||
agentStreamHead:
|
||||
stream.head === currentHead
|
||||
? session.agentStreamHead
|
||||
: new Map(session.agentStreamHead).set(agentId, stream.head),
|
||||
messageSubmissions,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
acceptAgentMessageSubmission: (serverId, agentId, clientMessageId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
||||
const submissions = acceptMessageSubmission(currentSubmissions, clientMessageId);
|
||||
if (submissions === currentSubmissions) return prev;
|
||||
const messageSubmissions = new Map(session.messageSubmissions);
|
||||
if (submissions.length > 0) {
|
||||
messageSubmissions.set(agentId, submissions);
|
||||
} else {
|
||||
messageSubmissions.delete(agentId);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, messageSubmissions },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
rejectAgentMessageSubmission: (serverId, agentId, clientMessageId) => {
|
||||
let outcome: MessageSubmissionRejectionOutcome = "unknown";
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
const currentTail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const currentHead = session.agentStreamHead.get(agentId) ?? [];
|
||||
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
||||
const result = rejectMessageSubmission(currentSubmissions, clientMessageId);
|
||||
outcome = result.outcome;
|
||||
if (outcome === "unknown") return prev;
|
||||
const stream =
|
||||
outcome === "rejected"
|
||||
? removeSubmittedUserMessage({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
clientMessageId,
|
||||
})
|
||||
: { tail: currentTail, head: currentHead };
|
||||
const messageSubmissions = new Map(session.messageSubmissions);
|
||||
if (result.submissions.length > 0) {
|
||||
messageSubmissions.set(agentId, result.submissions);
|
||||
} else {
|
||||
messageSubmissions.delete(agentId);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail:
|
||||
stream.tail === currentTail
|
||||
? session.agentStreamTail
|
||||
: new Map(session.agentStreamTail).set(agentId, stream.tail),
|
||||
agentStreamHead:
|
||||
stream.head === currentHead
|
||||
? session.agentStreamHead
|
||||
: new Map(session.agentStreamHead).set(agentId, stream.head),
|
||||
messageSubmissions,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
return outcome;
|
||||
},
|
||||
|
||||
handoffCreatedAgentUserMessage: (serverId, agentId, message) => {
|
||||
let didHandoff = false;
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const currentTail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const currentHead = session.agentStreamHead.get(agentId) ?? [];
|
||||
const result = handoffCreatedAgentUserMessageToStream({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
message,
|
||||
});
|
||||
if (!result.changedTail && !result.changedHead) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const nextTail = result.changedTail
|
||||
? new Map(session.agentStreamTail).set(agentId, result.tail)
|
||||
: session.agentStreamTail;
|
||||
const nextHead = result.changedHead
|
||||
? new Map(session.agentStreamHead).set(agentId, result.head)
|
||||
: session.agentStreamHead;
|
||||
didHandoff = true;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail: nextTail,
|
||||
agentStreamHead: nextHead,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
return didHandoff;
|
||||
},
|
||||
|
||||
clearAgentStreamHead: (serverId, agentId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
if (!session.agentStreamHead.has(agentId)) {
|
||||
return prev;
|
||||
}
|
||||
const nextHead = new Map(session.agentStreamHead);
|
||||
nextHead.delete(agentId);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentStreamHead: nextHead },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineCursor: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextState =
|
||||
typeof state === "function" ? state(session.agentTimelineCursor) : state;
|
||||
if (session.agentTimelineCursor === nextState) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentTimelineCursor: nextState },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineHasOlder: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextState =
|
||||
typeof state === "function" ? state(session.agentTimelineHasOlder) : state;
|
||||
if (session.agentTimelineHasOlder === nextState) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentTimelineHasOlder: nextState },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineHasNewer: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
const nextState =
|
||||
typeof state === "function" ? state(session.agentTimelineHasNewer) : state;
|
||||
if (session.agentTimelineHasNewer === nextState) return prev;
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentTimelineHasNewer: nextState },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineOlderFetchInFlight: (serverId, state) => {
|
||||
set((prev) => {
|
||||
@@ -914,58 +1321,107 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
markAgentHistorySynchronized: (serverId, agentId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const currentGeneration = session.historySyncGeneration;
|
||||
const previousGeneration = session.agentHistorySyncGeneration.get(agentId);
|
||||
if (previousGeneration === currentGeneration) {
|
||||
return prev;
|
||||
}
|
||||
const nextMap = new Map(session.agentHistorySyncGeneration);
|
||||
nextMap.set(agentId, currentGeneration);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentHistorySyncGeneration: nextMap,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setAgentAuthoritativeHistoryApplied: (serverId, agentId, applied) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const previousApplied = session.agentAuthoritativeHistoryApplied.get(agentId) ?? false;
|
||||
if (previousApplied === applied) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const nextApplied = new Map(session.agentAuthoritativeHistoryApplied);
|
||||
if (applied) {
|
||||
nextApplied.set(agentId, true);
|
||||
} else {
|
||||
nextApplied.delete(agentId);
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentAuthoritativeHistoryApplied: nextApplied,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
applyAgentTimelineResponseState: (serverId, agentId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
|
||||
let changed = false;
|
||||
const update = <T>(map: Map<string, T>, value: T | undefined): Map<string, T> => {
|
||||
if (map.get(agentId) === value) return map;
|
||||
changed = true;
|
||||
const next = new Map(map);
|
||||
if (value === undefined) next.delete(agentId);
|
||||
else next.set(agentId, value);
|
||||
return next;
|
||||
};
|
||||
const currentTail = session.agentStreamTail.get(agentId);
|
||||
const items =
|
||||
currentTail?.length === 0 && state.items.length === 0 ? currentTail : state.items;
|
||||
const nextTail = update(session.agentStreamTail, items);
|
||||
const nextHead = update(
|
||||
session.agentStreamHead,
|
||||
state.head.length > 0 ? state.head : undefined,
|
||||
const nextTail = new Map(session.agentStreamTail);
|
||||
nextTail.set(agentId, state.items);
|
||||
const nextHead = new Map(session.agentStreamHead);
|
||||
if (state.head.length > 0) nextHead.set(agentId, state.head);
|
||||
else nextHead.delete(agentId);
|
||||
const nextCursor = new Map(session.agentTimelineCursor);
|
||||
if (state.range) nextCursor.set(agentId, state.range);
|
||||
else nextCursor.delete(agentId);
|
||||
const nextHasOlder = new Map(session.agentTimelineHasOlder);
|
||||
if (state.older !== "unchanged") {
|
||||
nextHasOlder.set(agentId, state.older === "available");
|
||||
}
|
||||
const nextHasNewer = new Map(session.agentTimelineHasNewer);
|
||||
nextHasNewer.set(agentId, state.newer);
|
||||
const nextAuthoritative = new Map(session.agentAuthoritativeHistoryApplied);
|
||||
const nextSyncGeneration = new Map(session.agentHistorySyncGeneration);
|
||||
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
||||
const observedSubmissions = observeMessageSubmissionCanonical(
|
||||
currentSubmissions,
|
||||
state.acknowledgedClientMessageIds,
|
||||
);
|
||||
const currentRange = session.agentTimelineCursor.get(agentId);
|
||||
const rangeUnchanged = sameTimelineRange(currentRange, state.range);
|
||||
const nextCursor = rangeUnchanged
|
||||
? session.agentTimelineCursor
|
||||
: update(session.agentTimelineCursor, state.range ?? undefined);
|
||||
const nextHasOlder =
|
||||
state.older === "unchanged"
|
||||
? session.agentTimelineHasOlder
|
||||
: update(session.agentTimelineHasOlder, state.older === "available");
|
||||
const nextHasNewer = update(session.agentTimelineHasNewer, state.newer);
|
||||
const nextAuthoritative = state.synchronized
|
||||
? update(session.agentAuthoritativeHistoryApplied, true)
|
||||
: session.agentAuthoritativeHistoryApplied;
|
||||
const nextSyncGeneration = state.synchronized
|
||||
? update(session.agentHistorySyncGeneration, session.historySyncGeneration)
|
||||
: session.agentHistorySyncGeneration;
|
||||
const messageSubmissions = update(
|
||||
session.messageSubmissions,
|
||||
state.submissions.length > 0 ? state.submissions : undefined,
|
||||
);
|
||||
const rowsChanged =
|
||||
nextTail !== session.agentStreamTail || nextHead !== session.agentStreamHead;
|
||||
const agentTasks = rowsChanged
|
||||
? updateAgentTasks(
|
||||
session.agentTasks,
|
||||
agentId,
|
||||
latestTasksFromStream([...state.items, ...state.head]),
|
||||
)
|
||||
: session.agentTasks;
|
||||
if (!changed && agentTasks === session.agentTasks) return prev;
|
||||
let messageSubmissions = session.messageSubmissions;
|
||||
if (observedSubmissions !== currentSubmissions) {
|
||||
messageSubmissions = new Map(session.messageSubmissions);
|
||||
if (observedSubmissions.length > 0) {
|
||||
messageSubmissions.set(agentId, observedSubmissions);
|
||||
} else {
|
||||
messageSubmissions.delete(agentId);
|
||||
}
|
||||
}
|
||||
if (state.synchronized) {
|
||||
nextAuthoritative.set(agentId, true);
|
||||
nextSyncGeneration.set(agentId, session.historySyncGeneration);
|
||||
}
|
||||
const tasks = latestTasksFromStream([...state.items, ...state.head]);
|
||||
const agentTasks = new Map(session.agentTasks);
|
||||
if (tasks.length > 0) agentTasks.set(agentId, tasks);
|
||||
else agentTasks.delete(agentId);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
@@ -988,37 +1444,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
removeAgentTimeline: (serverId, agentId) => {
|
||||
set((previous) => {
|
||||
const session = previous.sessions[serverId];
|
||||
if (!session) return previous;
|
||||
const remove = <T>(map: Map<string, T>): Map<string, T> => {
|
||||
const next = new Map(map);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
};
|
||||
return {
|
||||
...previous,
|
||||
sessions: {
|
||||
...previous.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail: remove(session.agentStreamTail),
|
||||
agentStreamHead: remove(session.agentStreamHead),
|
||||
agentTimelineCursor: remove(session.agentTimelineCursor),
|
||||
agentTimelineHasOlder: remove(session.agentTimelineHasOlder),
|
||||
agentTimelineHasNewer: remove(session.agentTimelineHasNewer),
|
||||
agentTimelineOlderFetchInFlight: remove(session.agentTimelineOlderFetchInFlight),
|
||||
agentAuthoritativeHistoryApplied: remove(session.agentAuthoritativeHistoryApplied),
|
||||
agentHistorySyncGeneration: remove(session.agentHistorySyncGeneration),
|
||||
messageSubmissions: remove(session.messageSubmissions),
|
||||
agentTasks: remove(session.agentTasks),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (serverId, state) => {
|
||||
set((prev) => {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"agentId": "dd23a15e-0ece-4713-bff4-2f92b7293552",
|
||||
"items": [
|
||||
{
|
||||
"id": "e793b735-19ca-4dae-b8a2-ef79ada78b40",
|
||||
"timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 34 },
|
||||
"turnId": "95e36741-5c73-4472-ab3c-3ba03977acfb",
|
||||
"timestamp": "2026-08-29T11:04:38.577Z",
|
||||
"kind": "assistant_message",
|
||||
"messageId": "e793b735-19ca-4dae-b8a2-ef79ada78b40",
|
||||
"text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather\n\n_(end of synthetic stream)_\n"
|
||||
},
|
||||
{
|
||||
"id": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a",
|
||||
"timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 69 },
|
||||
"turnId": "c05e5c65-91dd-497e-8e59-92e045b4395f",
|
||||
"timestamp": "2026-08-29T11:19:09.051Z",
|
||||
"kind": "assistant_message",
|
||||
"messageId": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a",
|
||||
"text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather than guess. This kind of\n\n_(end of synthetic stream)_\n"
|
||||
},
|
||||
{
|
||||
"id": "8b0dcf06-f4a7-4238-8ba1-87a4e9daaf83",
|
||||
"timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 1 },
|
||||
"turnId": "95e36741-5c73-4472-ab3c-3ba03977acfb",
|
||||
"timestamp": "2026-08-29T11:04:36.573Z",
|
||||
"kind": "user_message",
|
||||
"messageId": "8b0dcf06-f4a7-4238-8ba1-87a4e9daaf83",
|
||||
"text": "Exercise the composer keyboard invariant flow"
|
||||
},
|
||||
{
|
||||
"id": "e793b735-19ca-4dae-b8a2-ef79ada78b40:segment:mte9wyi9",
|
||||
"timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 34 },
|
||||
"turnId": "95e36741-5c73-4472-ab3c-3ba03977acfb",
|
||||
"timestamp": "2026-08-29T11:04:38.577Z",
|
||||
"kind": "assistant_message",
|
||||
"messageId": "e793b735-19ca-4dae-b8a2-ef79ada78b40",
|
||||
"text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather\n\n_(end of synthetic stream)_\n"
|
||||
},
|
||||
{
|
||||
"id": "msg_1788002346771_1hfh7ud4z",
|
||||
"timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 35 },
|
||||
"turnId": "c05e5c65-91dd-497e-8e59-92e045b4395f",
|
||||
"timestamp": "2026-08-29T11:19:07.043Z",
|
||||
"kind": "user_message",
|
||||
"clientMessageId": "msg_1788002346771_1hfh7ud4z",
|
||||
"messageId": "msg_1788002346771_1hfh7ud4z",
|
||||
"text": "keyboard invariant line one\nline two\nline three\nline fourvv"
|
||||
},
|
||||
{
|
||||
"id": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a:segment:mteafm63",
|
||||
"timelineCursor": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "seq": 69 },
|
||||
"turnId": "c05e5c65-91dd-497e-8e59-92e045b4395f",
|
||||
"timestamp": "2026-08-29T11:19:09.051Z",
|
||||
"kind": "assistant_message",
|
||||
"messageId": "2cb83cb0-fecd-4f7b-baf4-43d5230be02a",
|
||||
"text": "## Cycle 1\n\nI'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather than guess. This kind of\n\n_(end of synthetic stream)_\n"
|
||||
}
|
||||
],
|
||||
"range": { "epoch": "67dcf593-69ca-4980-84b0-90c8756d5a05", "startSeq": 1, "endSeq": 69 },
|
||||
"hasOlder": false
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"entry": {
|
||||
"provider": "codex",
|
||||
"item": {
|
||||
"type": "assistant_message",
|
||||
"text": "\n\n---\n\nThe pending-tail check passed: the app requested catch-up after 10197 and recovered the live paragraph, with no subsequent live row delivered. I’m now checking that unpositioned updates can coexist with sequencing in the same session.\n",
|
||||
"messageId": "msg_050818abff3697f1016a9e2d065a4487d287f39b8cdf226b1a"
|
||||
},
|
||||
"timestamp": "2026-09-07T03:18:31.760Z",
|
||||
"seqStart": 10223,
|
||||
"seqEnd": 10243,
|
||||
"sourceSeqRanges": [
|
||||
{
|
||||
"startSeq": 10223,
|
||||
"endSeq": 10243
|
||||
}
|
||||
],
|
||||
"turnId": "codex-turn-64",
|
||||
"collapsed": ["assistant_merge"]
|
||||
},
|
||||
"chunks": [
|
||||
"\n\n---\n\nThe",
|
||||
" pending-tail",
|
||||
" check passed",
|
||||
": the",
|
||||
" app requested",
|
||||
" catch-up",
|
||||
" after ",
|
||||
"10197",
|
||||
" and recovered the",
|
||||
" live paragraph",
|
||||
", with no",
|
||||
" subsequent live",
|
||||
" row delivered",
|
||||
". I",
|
||||
"’m now checking that",
|
||||
" unposition",
|
||||
"ed",
|
||||
" updates can coexist with",
|
||||
" sequencing in",
|
||||
" the same",
|
||||
" session.\n"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { fetchAgentTimelineOnce } from "./fetch-agent-timeline-once";
|
||||
|
||||
type TimelinePage = Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>;
|
||||
|
||||
test("concurrent identical timeline reads share one request", async () => {
|
||||
let resolvePage: (page: TimelinePage) => void = () => {};
|
||||
const page = new Promise<TimelinePage>((resolve) => {
|
||||
resolvePage = resolve;
|
||||
});
|
||||
const requests: Array<{ agentId: string; direction: string }> = [];
|
||||
const client = {
|
||||
fetchAgentTimeline: async (
|
||||
agentId: string,
|
||||
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
|
||||
) => {
|
||||
requests.push({ agentId, direction: request?.direction ?? "tail" });
|
||||
return page;
|
||||
},
|
||||
};
|
||||
const request = { direction: "tail" as const, limit: 100, projection: "projected" as const };
|
||||
|
||||
const first = fetchAgentTimelineOnce(client, "agent", request);
|
||||
const second = fetchAgentTimelineOnce(client, "agent", request);
|
||||
resolvePage({ hasNewer: false } as TimelinePage);
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
|
||||
expect(requests).toEqual([{ agentId: "agent", direction: "tail" }]);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
type TimelineClient = Pick<DaemonClient, "fetchAgentTimeline">;
|
||||
type TimelineRequest = Parameters<TimelineClient["fetchAgentTimeline"]>[1];
|
||||
type TimelinePage = Awaited<ReturnType<TimelineClient["fetchAgentTimeline"]>>;
|
||||
|
||||
const inFlightByClient = new WeakMap<object, Map<string, Promise<TimelinePage>>>();
|
||||
|
||||
export function fetchAgentTimelineOnce(
|
||||
client: TimelineClient,
|
||||
agentId: string,
|
||||
request: TimelineRequest,
|
||||
): Promise<TimelinePage> {
|
||||
let inFlight = inFlightByClient.get(client);
|
||||
if (!inFlight) {
|
||||
inFlight = new Map();
|
||||
inFlightByClient.set(client, inFlight);
|
||||
}
|
||||
|
||||
const key = `${agentId}:${JSON.stringify(request)}`;
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const fetch = client.fetchAgentTimeline(agentId, request);
|
||||
inFlight.set(key, fetch);
|
||||
const clear = () => {
|
||||
if (inFlight.get(key) === fetch) inFlight.delete(key);
|
||||
};
|
||||
void fetch.then(clear, clear);
|
||||
return fetch;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,752 +0,0 @@
|
||||
import type { CachedTimeline } from "@/runtime/replica-cache";
|
||||
import equal from "fast-deep-equal";
|
||||
import type { AgentRemovalReason } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
acceptMessageSubmission,
|
||||
beginMessageSubmission,
|
||||
getSendingClientMessageIds,
|
||||
observeMessageSubmissionCanonical,
|
||||
rejectMessageSubmission,
|
||||
type MessageSubmissionRecord,
|
||||
type MessageSubmissionRejectionOutcome,
|
||||
} from "@/composer/submission/model";
|
||||
import {
|
||||
appendSubmittedUserMessage,
|
||||
handoffCreatedAgentUserMessageToStream,
|
||||
removeSubmittedUserMessage,
|
||||
replaceWithCanonicalStream,
|
||||
isUnreconciledLocalUserMessage,
|
||||
type StreamItem,
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import {
|
||||
getInitDeferred,
|
||||
getInitKey,
|
||||
rejectInitDeferred,
|
||||
resolveInitDeferred,
|
||||
} from "@/utils/agent-initialization";
|
||||
import {
|
||||
processAgentStreamEvents,
|
||||
processTimelineResponse,
|
||||
processTimelineResponseCompletion,
|
||||
type AgentStreamReducerEvent,
|
||||
type AgentStreamReducerSnapshot,
|
||||
type ProcessTimelineResponseOutput,
|
||||
} from "./session-stream-reducers";
|
||||
import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "./timeline-fetch-policy";
|
||||
|
||||
export type TimelineResponsePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "fetch_agent_timeline_response" }
|
||||
>["payload"];
|
||||
|
||||
export interface TimelinePageRequest {
|
||||
cursor: { epoch: string; endSeq: number } | null;
|
||||
replaceTail: boolean;
|
||||
initialization: Promise<void> | undefined;
|
||||
}
|
||||
|
||||
export interface TimelineReplicaStorage {
|
||||
removeTimeline(serverId: string, agentId: string): void;
|
||||
readTimeline(serverId: string, agentId: string): Promise<CachedTimeline | undefined>;
|
||||
commitTimeline(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
timeline: CachedTimeline,
|
||||
requireCertified?: boolean,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface TimelineRows {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
}
|
||||
|
||||
interface VerifiedTimeline extends TimelineRows {
|
||||
range: AgentTimelineCursorState | null;
|
||||
hasOlder: boolean;
|
||||
}
|
||||
|
||||
interface AgentTimeline extends TimelineRows {
|
||||
// Pagination and coverage certainty do not change which older entries belong to the restart page.
|
||||
restartStartSeq: number | null;
|
||||
verified: VerifiedTimeline | null;
|
||||
// Loaded positions support navigation even when the displayed content is uncertifiable.
|
||||
displayRange: AgentTimelineCursorState | null;
|
||||
older: boolean;
|
||||
newer: boolean;
|
||||
synchronized: boolean;
|
||||
hasUnsequencedHistory: boolean;
|
||||
submissions: MessageSubmissionRecord[];
|
||||
preparation?: Promise<void>;
|
||||
restored: boolean;
|
||||
liveEpoch?: string;
|
||||
}
|
||||
|
||||
interface PageEffects {
|
||||
recoverGap(
|
||||
agentId: string,
|
||||
cursor: { epoch: string; endSeq: number },
|
||||
observedSeq?: number,
|
||||
): void;
|
||||
drainQueuedAgentMessage(agentId: string): void;
|
||||
}
|
||||
|
||||
function acceptLiveDisplayEpoch(
|
||||
state: AgentTimeline,
|
||||
events: AgentStreamReducerEvent[],
|
||||
): AgentStreamReducerEvent[] {
|
||||
if (state.synchronized) return events;
|
||||
// A connection's live epoch owns the display even while its first page is pending. The saved
|
||||
// epoch remains a restart baseline until a page establishes a replacement.
|
||||
const epoch = events.findLast((event) => event.event.type === "timeline" && event.epoch)?.epoch;
|
||||
if (!epoch) return events;
|
||||
const previousEpoch = state.liveEpoch ?? state.displayRange?.epoch;
|
||||
if (previousEpoch && previousEpoch !== epoch) {
|
||||
state.tail = state.tail.filter(
|
||||
(item) => item.kind === "user_message" && isUnreconciledLocalUserMessage(item),
|
||||
);
|
||||
state.head = state.head.filter(
|
||||
(item) => item.kind === "user_message" && isUnreconciledLocalUserMessage(item),
|
||||
);
|
||||
state.older = false;
|
||||
state.displayRange = null;
|
||||
state.restartStartSeq = null;
|
||||
state.hasUnsequencedHistory = false;
|
||||
}
|
||||
state.liveEpoch = epoch;
|
||||
return events.filter((event) => !event.epoch || event.epoch === epoch);
|
||||
}
|
||||
|
||||
function applyVerifiedStreamEvents(
|
||||
state: AgentTimeline,
|
||||
events: AgentStreamReducerEvent[],
|
||||
display: ReturnType<typeof processAgentStreamEvents>,
|
||||
): boolean {
|
||||
const previous = state.verified;
|
||||
const positioned = events.filter(
|
||||
({ event, seq, epoch }) =>
|
||||
event.type !== "timeline" || (typeof seq === "number" && typeof epoch === "string"),
|
||||
);
|
||||
const unsequencedChanged =
|
||||
positioned.length !== events.length && (display.changedTail || display.changedHead);
|
||||
if (unsequencedChanged) state.hasUnsequencedHistory = true;
|
||||
if (!previous || !state.synchronized) return unsequencedChanged;
|
||||
const accepted =
|
||||
positioned.length === events.length &&
|
||||
state.tail === previous.tail &&
|
||||
state.head === previous.head
|
||||
? display
|
||||
: processAgentStreamEvents({
|
||||
events: positioned,
|
||||
currentTail: previous.tail,
|
||||
currentHead: previous.head,
|
||||
currentCursor: previous.range ?? undefined,
|
||||
hasAuthoritativeBaseline: true,
|
||||
isDetached: state.newer,
|
||||
});
|
||||
const cursor = accepted.cursor;
|
||||
const changedEpoch =
|
||||
cursor !== null && previous.range !== null && cursor.epoch !== previous.range.epoch;
|
||||
if (changedEpoch) {
|
||||
Object.assign(display, retainPendingSubmissions(display, state));
|
||||
if (accepted !== display) Object.assign(accepted, retainPendingSubmissions(accepted, state));
|
||||
state.older = false;
|
||||
state.hasUnsequencedHistory = unsequencedChanged;
|
||||
state.restartStartSeq = cursor.startSeq;
|
||||
}
|
||||
state.verified = {
|
||||
tail: accepted.tail,
|
||||
head: accepted.head,
|
||||
range: accepted.cursorChanged ? cursor : previous.range,
|
||||
hasOlder: changedEpoch ? false : previous.hasOlder,
|
||||
};
|
||||
return (
|
||||
unsequencedChanged || accepted.changedTail || accepted.changedHead || accepted.cursorChanged
|
||||
);
|
||||
}
|
||||
|
||||
function applySubmissionRows(
|
||||
state: AgentTimeline,
|
||||
transition: (rows: TimelineRows) => TimelineRows,
|
||||
): boolean {
|
||||
const verified = state.verified;
|
||||
const shared = verified && state.tail === verified.tail && state.head === verified.head;
|
||||
const result = transition(state);
|
||||
const changed = result.tail !== state.tail || result.head !== state.head;
|
||||
if (verified) {
|
||||
const accepted = shared ? result : transition(verified);
|
||||
verified.tail = accepted.tail;
|
||||
verified.head = accepted.head;
|
||||
}
|
||||
state.tail = result.tail;
|
||||
state.head = result.head;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function isSupersededTail(
|
||||
payload: TimelineResponsePayload,
|
||||
current: AgentTimelineCursorState | null | undefined,
|
||||
requestedFrom: { epoch: string; endSeq: number } | null | undefined,
|
||||
): boolean {
|
||||
if (payload.direction !== "tail" || payload.reset || payload.mergeWindow || payload.error)
|
||||
return false;
|
||||
// A head already behind the request's accepted cursor demonstrates a rewind. A head
|
||||
// passed by activity accepted during that request is an obsolete reply instead.
|
||||
return (
|
||||
requestedFrom !== undefined &&
|
||||
(requestedFrom === null ||
|
||||
requestedFrom.epoch !== payload.epoch ||
|
||||
payload.window.maxSeq >= requestedFrom.endSeq) &&
|
||||
current?.epoch === payload.epoch &&
|
||||
payload.window.maxSeq < current.endSeq
|
||||
);
|
||||
}
|
||||
|
||||
function invalidatesDurableBaseline(
|
||||
payload: TimelineResponsePayload,
|
||||
result: ProcessTimelineResponseOutput,
|
||||
baseline: VerifiedTimeline | null,
|
||||
): boolean {
|
||||
const completeReplacement =
|
||||
payload.direction === "tail" &&
|
||||
(payload.window.maxSeq < payload.window.minSeq ||
|
||||
(payload.startCursor?.seq === payload.window.minSeq &&
|
||||
payload.endCursor?.seq === payload.window.maxSeq));
|
||||
return Boolean(
|
||||
result.commit === "apply" &&
|
||||
completeReplacement &&
|
||||
(payload.reset ||
|
||||
(baseline?.range &&
|
||||
(payload.epoch !== baseline.range.epoch || payload.window.maxSeq < baseline.range.endSeq))),
|
||||
);
|
||||
}
|
||||
|
||||
function applyAcceptedPageState(input: {
|
||||
state: AgentTimeline;
|
||||
payload: TimelineResponsePayload;
|
||||
result: ProcessTimelineResponseOutput;
|
||||
verified: VerifiedTimeline;
|
||||
replaceTail: boolean;
|
||||
synchronized: boolean;
|
||||
}): void {
|
||||
const { state, payload, result, verified, synchronized } = input;
|
||||
const baseline = state.verified;
|
||||
const range = verified.range;
|
||||
const replacesWindow =
|
||||
input.replaceTail ||
|
||||
payload.reset ||
|
||||
!baseline?.range ||
|
||||
!range ||
|
||||
baseline.range.epoch !== range.epoch ||
|
||||
range.endSeq < baseline.range.endSeq;
|
||||
// Older pagination can extend an existing baseline, but cannot establish live coverage.
|
||||
if (payload.direction !== "before" || baseline) {
|
||||
if (replacesWindow) state.restartStartSeq = range?.startSeq ?? null;
|
||||
state.verified = verified;
|
||||
if (payload.direction !== "before") state.hasUnsequencedHistory = false;
|
||||
}
|
||||
state.tail = result.tail;
|
||||
state.head = result.head;
|
||||
if (result.cursorChanged) state.displayRange = result.cursor ?? null;
|
||||
if (result.older !== "unchanged") state.older = result.older === "available";
|
||||
if (payload.direction !== "before") state.newer = payload.hasNewer;
|
||||
if (synchronized) state.liveEpoch = payload.epoch;
|
||||
state.synchronized ||= synchronized;
|
||||
state.submissions = observeMessageSubmissionCanonical(
|
||||
state.submissions,
|
||||
result.acknowledgedClientMessageIds,
|
||||
);
|
||||
}
|
||||
|
||||
/** Host-lived write authority. Zustand is its UI projection; disk is write-behind. */
|
||||
export class TimelineReplica {
|
||||
private readonly agents = new Map<string, AgentTimeline>();
|
||||
private readonly removed = new Set<string>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private serverId: string,
|
||||
private readonly storage: TimelineReplicaStorage,
|
||||
) {}
|
||||
|
||||
private state(agentId: string): AgentTimeline {
|
||||
const existing = this.agents.get(agentId);
|
||||
if (existing) return existing;
|
||||
const state: AgentTimeline = {
|
||||
tail: [],
|
||||
head: [],
|
||||
older: false,
|
||||
newer: false,
|
||||
synchronized: false,
|
||||
hasUnsequencedHistory: false,
|
||||
restored: false,
|
||||
restartStartSeq: null,
|
||||
verified: null,
|
||||
displayRange: null,
|
||||
submissions: [],
|
||||
};
|
||||
this.agents.set(agentId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private accepts(agentId: string): boolean {
|
||||
return !this.disposed && !this.removed.has(agentId);
|
||||
}
|
||||
|
||||
prepare(agentId: string): Promise<void> {
|
||||
if (!this.accepts(agentId)) return Promise.resolve();
|
||||
const state = this.state(agentId);
|
||||
state.preparation ??= this.restore(agentId, state);
|
||||
return state.preparation;
|
||||
}
|
||||
|
||||
private async restore(agentId: string, state: AgentTimeline): Promise<void> {
|
||||
const serverId = this.serverId;
|
||||
const stored = await this.storage.readTimeline(serverId, agentId);
|
||||
if (!this.accepts(agentId) || this.agents.get(agentId) !== state) return;
|
||||
// Identity transfer retires the old scoped read, even if it captured rows before rename.
|
||||
if (serverId !== this.serverId) return this.restore(agentId, state);
|
||||
state.restored = true;
|
||||
if (state.verified || !stored) {
|
||||
this.publish(agentId, state, {
|
||||
persist: Boolean(state.verified) || state.hasUnsequencedHistory,
|
||||
requireCertified: Boolean(stored?.range),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const sameEpoch = !state.liveEpoch || !stored.range || state.liveEpoch === stored.range.epoch;
|
||||
const replacement = replaceWithCanonicalStream({
|
||||
canonical: sameEpoch ? stored.items : [],
|
||||
previousTail: state.tail,
|
||||
previousHead: state.head,
|
||||
sendingClientMessageIds: getSendingClientMessageIds(state.submissions),
|
||||
preserveContinuity: true,
|
||||
canonicalCoverage:
|
||||
sameEpoch && stored.range
|
||||
? { epoch: stored.range.epoch, endSeq: stored.range.endSeq }
|
||||
: "rows-only",
|
||||
});
|
||||
Object.assign(state, {
|
||||
tail: replacement.tail,
|
||||
head: replacement.head,
|
||||
older: sameEpoch && stored.hasOlder,
|
||||
displayRange: sameEpoch ? stored.range : null,
|
||||
});
|
||||
if (stored.range) {
|
||||
state.restartStartSeq = stored.range.startSeq;
|
||||
state.verified = {
|
||||
tail: stored.items,
|
||||
head: [],
|
||||
range: stored.range,
|
||||
hasOlder: stored.hasOlder,
|
||||
};
|
||||
}
|
||||
state.submissions = observeMessageSubmissionCanonical(
|
||||
state.submissions,
|
||||
replacement.acknowledgedClientMessageIds,
|
||||
);
|
||||
this.publish(agentId, state, {
|
||||
persist: state.hasUnsequencedHistory,
|
||||
requireCertified: Boolean(stored.range),
|
||||
});
|
||||
}
|
||||
|
||||
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined {
|
||||
if (!this.accepts(agentId)) return undefined;
|
||||
const range = this.state(agentId).verified?.range;
|
||||
return range ? { epoch: range.epoch, endSeq: range.endSeq } : undefined;
|
||||
}
|
||||
|
||||
private streamSnapshot(agentId: string): AgentStreamReducerSnapshot {
|
||||
const state = this.state(agentId);
|
||||
return {
|
||||
currentTail: state.tail,
|
||||
currentHead: state.head,
|
||||
currentCursor: state.displayRange ?? undefined,
|
||||
// Before network synchronization, live activity can paint ahead of the saved range.
|
||||
hasAuthoritativeBaseline: state.synchronized,
|
||||
isDetached: state.synchronized && state.newer,
|
||||
};
|
||||
}
|
||||
|
||||
applyEvents(
|
||||
agentId: string,
|
||||
events: AgentStreamReducerEvent[],
|
||||
recoverGap: PageEffects["recoverGap"],
|
||||
): void {
|
||||
if (!this.accepts(agentId)) return;
|
||||
const state = this.state(agentId);
|
||||
events = acceptLiveDisplayEpoch(state, events);
|
||||
const display = processAgentStreamEvents({ events, ...this.streamSnapshot(agentId) });
|
||||
const previous = state.verified;
|
||||
const persist = applyVerifiedStreamEvents(state, events, display);
|
||||
if (display.cursorChanged) state.displayRange = display.cursor;
|
||||
state.tail = display.tail;
|
||||
state.head = display.head;
|
||||
state.submissions = observeMessageSubmissionCanonical(
|
||||
state.submissions,
|
||||
display.acknowledgedClientMessageIds,
|
||||
);
|
||||
this.publish(agentId, state, {
|
||||
persist: persist || !previous,
|
||||
requireCertified: Boolean(previous?.range && state.verified),
|
||||
});
|
||||
for (const effect of display.sideEffects) {
|
||||
if (effect.type === "catch_up") recoverGap(agentId, effect.cursor, effect.observedSeq);
|
||||
}
|
||||
}
|
||||
|
||||
applyPage(
|
||||
payload: TimelineResponsePayload,
|
||||
effects: PageEffects,
|
||||
request?: TimelinePageRequest,
|
||||
): void {
|
||||
const agentId = payload.agentId;
|
||||
if (!this.accepts(agentId)) return;
|
||||
const state = this.state(agentId);
|
||||
const initKey = getInitKey(this.serverId, agentId);
|
||||
const deferred = getInitDeferred(initKey);
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
const ownsInitialization = !request || request.initialization === deferred?.promise;
|
||||
const common = {
|
||||
payload,
|
||||
replaceTail: request?.replaceTail === true,
|
||||
currentCursor: state.displayRange ?? undefined,
|
||||
isInitializing: ownsInitialization && session?.initializingAgents.get(agentId) === true,
|
||||
hasActiveInitDeferred: ownsInitialization && Boolean(deferred),
|
||||
initRequestDirection: deferred?.requestDirection ?? ("tail" as const),
|
||||
sendingClientMessageIds: getSendingClientMessageIds(state.submissions),
|
||||
};
|
||||
if (isSupersededTail(payload, state.verified?.range, request?.cursor)) {
|
||||
const completion = processTimelineResponseCompletion(common);
|
||||
settleTimelineInitialization(this.serverId, agentId, completion);
|
||||
return;
|
||||
}
|
||||
const result = processTimelineResponse({
|
||||
...common,
|
||||
currentTail: state.tail,
|
||||
currentHead: state.head,
|
||||
});
|
||||
if (result.error) {
|
||||
settleTimelineInitialization(this.serverId, agentId, result);
|
||||
return;
|
||||
}
|
||||
const baseline = state.verified;
|
||||
const invalidated = invalidatesDurableBaseline(payload, result, baseline);
|
||||
if (invalidated) {
|
||||
this.storage.removeTimeline(this.serverId, agentId);
|
||||
state.restored = true;
|
||||
}
|
||||
const synchronized = isTimelineResumeSnapshotAuthoritative({
|
||||
direction: payload.direction,
|
||||
hasNewer: payload.hasNewer,
|
||||
error: payload.error,
|
||||
});
|
||||
applyAcceptedPageState({
|
||||
state,
|
||||
payload,
|
||||
result,
|
||||
verified: reduceVerifiedPage(state, common, result),
|
||||
replaceTail: common.replaceTail,
|
||||
synchronized,
|
||||
});
|
||||
this.publish(agentId, state, {
|
||||
persist: payload.direction !== "before",
|
||||
requireCertified: !invalidated && Boolean(baseline?.range),
|
||||
synchronized,
|
||||
});
|
||||
if (!this.accepts(agentId)) return;
|
||||
recoverDisplayedGap(agentId, state, effects.recoverGap);
|
||||
finalizeProcessedTimeline({
|
||||
serverId: this.serverId,
|
||||
agentId,
|
||||
result,
|
||||
synchronized,
|
||||
...effects,
|
||||
});
|
||||
}
|
||||
|
||||
beginSubmission(agentId: string, message: UserMessageItem, tracked: boolean): void {
|
||||
if (!this.accepts(agentId)) return;
|
||||
const state = this.state(agentId);
|
||||
if (tracked) {
|
||||
if (!message.clientMessageId)
|
||||
throw new Error("Beginning a message submission requires client identity");
|
||||
state.submissions = beginMessageSubmission(state.submissions, {
|
||||
clientMessageId: message.clientMessageId,
|
||||
});
|
||||
}
|
||||
applySubmissionRows(state, (rows) => appendSubmittedUserMessage({ ...rows, message }));
|
||||
this.publish(agentId, state);
|
||||
}
|
||||
|
||||
acceptSubmission(agentId: string, clientMessageId: string): void {
|
||||
if (!this.accepts(agentId)) return;
|
||||
const state = this.state(agentId);
|
||||
state.submissions = acceptMessageSubmission(state.submissions, clientMessageId);
|
||||
this.publish(agentId, state);
|
||||
}
|
||||
|
||||
rejectSubmission(
|
||||
agentId: string,
|
||||
clientMessageId: string,
|
||||
tracked: boolean,
|
||||
): MessageSubmissionRejectionOutcome {
|
||||
if (!this.accepts(agentId)) return "unknown";
|
||||
const state = this.state(agentId);
|
||||
const rejection = rejectMessageSubmission(state.submissions, clientMessageId);
|
||||
const outcome = tracked ? rejection.outcome : "rejected";
|
||||
state.submissions = rejection.submissions;
|
||||
if (outcome === "rejected") {
|
||||
applySubmissionRows(state, (rows) =>
|
||||
removeSubmittedUserMessage({ ...rows, clientMessageId }),
|
||||
);
|
||||
}
|
||||
this.publish(agentId, state);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
handoffSubmission(agentId: string, message: UserMessageItem): boolean {
|
||||
if (!this.accepts(agentId)) return false;
|
||||
const state = this.state(agentId);
|
||||
const changed = applySubmissionRows(state, (rows) =>
|
||||
handoffCreatedAgentUserMessageToStream({ ...rows, message }),
|
||||
);
|
||||
if (!changed) return false;
|
||||
this.publish(agentId, state, {
|
||||
persist: Boolean(state.verified),
|
||||
requireCertified: Boolean(state.verified?.range),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
acceptAgent(agentId: string): void {
|
||||
if (!this.disposed) this.removed.delete(agentId);
|
||||
}
|
||||
|
||||
remove(agentId: string, reason: AgentRemovalReason): void {
|
||||
this.removed.add(agentId);
|
||||
this.agents.delete(agentId);
|
||||
if (reason === "deleted") this.storage.removeTimeline(this.serverId, agentId);
|
||||
useSessionStore.getState().removeAgentTimeline(this.serverId, agentId);
|
||||
}
|
||||
|
||||
reconcileServerId(serverId: string): void {
|
||||
this.serverId = serverId;
|
||||
for (const [agentId, state] of this.agents) {
|
||||
this.publish(agentId, state, { synchronized: state.synchronized });
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.agents.clear();
|
||||
}
|
||||
|
||||
private publish(
|
||||
agentId: string,
|
||||
state: AgentTimeline,
|
||||
options: { persist?: boolean; requireCertified?: boolean; synchronized?: boolean } = {},
|
||||
): void {
|
||||
const { persist = false, requireCertified = false, synchronized = false } = options;
|
||||
// Unknown display content cannot certify disk, but does not revoke known source coverage.
|
||||
// While positioned catch-up is incomplete, keep its accepted durable baseline instead.
|
||||
const displayOnly = state.hasUnsequencedHistory && state.synchronized && !state.newer;
|
||||
const verified = displayOnly ? null : state.verified;
|
||||
const range = verified?.range ?? null;
|
||||
const durable: CachedTimeline | null =
|
||||
persist && (verified !== null || state.restored)
|
||||
? {
|
||||
agentId,
|
||||
items: verified ? [...verified.tail, ...verified.head] : [...state.tail, ...state.head],
|
||||
range,
|
||||
hasOlder: Boolean(verified?.hasOlder),
|
||||
}
|
||||
: null;
|
||||
// Both outputs are determined before subscribers run; none can authorize persistence by
|
||||
// changing a mutable store flag between publication and the write-behind decision.
|
||||
if (durable) {
|
||||
const page = latestRestartPage(durable, state.restartStartSeq);
|
||||
if (page.range) state.restartStartSeq = page.range.startSeq;
|
||||
this.storage.commitTimeline(
|
||||
this.serverId,
|
||||
agentId,
|
||||
page,
|
||||
(requireCertified && !displayOnly) || !state.restored,
|
||||
);
|
||||
}
|
||||
useSessionStore.getState().applyAgentTimelineResponseState(this.serverId, agentId, {
|
||||
items: state.tail,
|
||||
head: state.head,
|
||||
range: state.displayRange,
|
||||
older: state.older ? "available" : "none",
|
||||
newer: state.newer,
|
||||
synchronized,
|
||||
submissions: state.submissions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function latestRestartPage(
|
||||
timeline: CachedTimeline,
|
||||
restartStartSeq: number | null,
|
||||
): CachedTimeline {
|
||||
const startOf = (item: StreamItem | undefined) =>
|
||||
item?.source?.startSeq ?? item?.timelineCursor?.seq;
|
||||
const candidates = timeline.items.filter(
|
||||
(item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item),
|
||||
);
|
||||
const items = candidates.filter(
|
||||
(item) =>
|
||||
restartStartSeq === null ||
|
||||
(startOf(item) ?? restartStartSeq) >= restartStartSeq ||
|
||||
// A prepend may complete the retained boundary message. Keep that whole item,
|
||||
// without admitting older tools merely because they finish inside the window.
|
||||
item.source?.chunks?.some((chunk) => chunk.seq === restartStartSeq),
|
||||
);
|
||||
const first = Math.max(0, items.length - TIMELINE_FETCH_PAGE_SIZE);
|
||||
const omitted = candidates.length > items.length || first > 0;
|
||||
const firstSource = startOf(items[first]);
|
||||
const floor = restartStartSeq ?? timeline.range?.startSeq;
|
||||
const startSeq = first > 0 ? firstSource : Math.min(floor ?? Infinity, firstSource ?? Infinity);
|
||||
const range =
|
||||
timeline.range && startSeq !== undefined && Number.isFinite(startSeq)
|
||||
? { ...timeline.range, startSeq: Math.max(timeline.range.startSeq, startSeq) }
|
||||
: timeline.range;
|
||||
return {
|
||||
...timeline,
|
||||
items: items.slice(first),
|
||||
range,
|
||||
hasOlder:
|
||||
timeline.hasOlder ||
|
||||
omitted ||
|
||||
Boolean(range && timeline.range && range.startSeq > timeline.range.startSeq),
|
||||
};
|
||||
}
|
||||
|
||||
export function createTimelineReplica(input: {
|
||||
serverId: string;
|
||||
storage: TimelineReplicaStorage;
|
||||
}): TimelineReplica {
|
||||
return new TimelineReplica(input.serverId, input.storage);
|
||||
}
|
||||
function retainPendingSubmissions(rows: TimelineRows, state: AgentTimeline): TimelineRows {
|
||||
const pending = getSendingClientMessageIds(state.submissions);
|
||||
for (const row of [...state.tail, ...state.head]) {
|
||||
if (
|
||||
row.kind !== "user_message" ||
|
||||
!row.clientMessageId ||
|
||||
!pending.includes(row.clientMessageId)
|
||||
)
|
||||
continue;
|
||||
rows = handoffCreatedAgentUserMessageToStream({ ...rows, message: row });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function reduceVerifiedPage(
|
||||
state: AgentTimeline,
|
||||
common: Omit<Parameters<typeof processTimelineResponse>[0], "currentTail" | "currentHead">,
|
||||
display: ProcessTimelineResponseOutput,
|
||||
): VerifiedTimeline {
|
||||
const baseline = state.verified;
|
||||
const currentCursor = baseline?.range ?? undefined;
|
||||
const shared =
|
||||
(baseline === null && state.tail.length === 0 && state.head.length === 0) ||
|
||||
(baseline?.tail === state.tail && baseline.head === state.head);
|
||||
let rows: TimelineRows = baseline ?? { tail: [], head: [] };
|
||||
for (const row of shared ? [] : [...state.tail, ...state.head]) {
|
||||
if (row.kind === "user_message" && isUnreconciledLocalUserMessage(row)) {
|
||||
rows = handoffCreatedAgentUserMessageToStream({ ...rows, message: row });
|
||||
}
|
||||
}
|
||||
const verified = shared
|
||||
? display
|
||||
: processTimelineResponse({
|
||||
...common,
|
||||
currentCursor,
|
||||
currentTail: rows.tail,
|
||||
currentHead: rows.head,
|
||||
});
|
||||
// Divergence ends when the accepted page reconciles the overlay. Subsequent events share
|
||||
// one reduction again; no consumer or synchronization flag decides row identity.
|
||||
if (!shared && equal([...verified.tail, ...verified.head], [...display.tail, ...display.head])) {
|
||||
verified.tail = display.tail;
|
||||
verified.head = display.head;
|
||||
}
|
||||
const range = verified.cursorChanged ? (verified.cursor ?? null) : (currentCursor ?? null);
|
||||
return {
|
||||
tail: verified.tail,
|
||||
head: verified.head,
|
||||
range,
|
||||
hasOlder: verified.older === "unchanged" ? state.older : verified.older === "available",
|
||||
};
|
||||
}
|
||||
|
||||
function recoverDisplayedGap(
|
||||
agentId: string,
|
||||
state: AgentTimeline,
|
||||
recoverGap: PageEffects["recoverGap"],
|
||||
): void {
|
||||
const range = state.verified?.range;
|
||||
if (!range) return;
|
||||
for (const row of [...state.tail, ...state.head]) {
|
||||
const cursor = row.timelineCursor;
|
||||
if (cursor?.epoch === range.epoch && cursor.seq > range.endSeq)
|
||||
recoverGap(agentId, range, cursor.seq);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAgentInitializingFlag(serverId: string, agentId: string): void {
|
||||
useSessionStore.getState().setInitializingAgents(serverId, (previous) => {
|
||||
if (previous.get(agentId) !== true) return previous;
|
||||
const next = new Map(previous);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function settleTimelineInitialization(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
result: Pick<ProcessTimelineResponseOutput, "clearInitializing" | "initResolution" | "error">,
|
||||
): void {
|
||||
if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId);
|
||||
const key = getInitKey(serverId, agentId);
|
||||
if (result.initResolution === "resolve") resolveInitDeferred(key);
|
||||
if (result.initResolution === "reject" && result.error)
|
||||
rejectInitDeferred(key, new Error(result.error));
|
||||
}
|
||||
|
||||
function finalizeProcessedTimeline(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
result: ProcessTimelineResponseOutput;
|
||||
synchronized: boolean;
|
||||
recoverGap: (
|
||||
agentId: string,
|
||||
cursor: { epoch: string; endSeq: number },
|
||||
observedSeq?: number,
|
||||
) => void;
|
||||
drainQueuedAgentMessage: (agentId: string) => void;
|
||||
}): void {
|
||||
for (const effect of input.result.sideEffects) {
|
||||
if (effect.type === "catch_up") {
|
||||
input.recoverGap(input.agentId, effect.cursor, effect.observedSeq);
|
||||
}
|
||||
}
|
||||
settleTimelineInitialization(input.serverId, input.agentId, input.result);
|
||||
if (input.synchronized) {
|
||||
useCreateFlowStore
|
||||
.getState()
|
||||
.clearByAgent({ serverId: input.serverId, agentId: input.agentId });
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
|
||||
if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import type { AgentTimelineItem, ToolCallDetail } from "@getpaseo/protocol/agent
|
||||
import {
|
||||
createUserMessage,
|
||||
hydrateStreamState,
|
||||
rowStartSeq,
|
||||
type AgentToolCallItem,
|
||||
type StreamItem,
|
||||
} from "@/types/stream";
|
||||
@@ -482,12 +481,7 @@ describe("processTimelineResponse", () => {
|
||||
it("replaces destructively when the same epoch rewinds behind the local cursor", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [
|
||||
{
|
||||
...makeAssistantItem("future history", "future-history"),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 40 },
|
||||
},
|
||||
],
|
||||
currentTail: [makeAssistantItem("future history", "future-history")],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
@@ -499,37 +493,10 @@ describe("processTimelineResponse", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["rewound history"]);
|
||||
expect(getAssistantTexts(result.tail)).toEqual(["rewound history"]);
|
||||
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 10 });
|
||||
});
|
||||
|
||||
it("drops superseded positioned rows when a forced reset tail ends behind the cursor", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [
|
||||
{
|
||||
...makeAssistantItem("future history", "future-history"),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 40 },
|
||||
},
|
||||
],
|
||||
currentHead: [makeSubmittedUserMessage("still sending", "sending-prompt")],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 },
|
||||
sendingClientMessageIds: ["sending-prompt"],
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "tail",
|
||||
reset: true,
|
||||
window: { minSeq: 1, maxSeq: 10, nextSeq: 11 },
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 10 },
|
||||
entries: [makeTimelineEntry(10, "rewound history")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["rewound history"]);
|
||||
expect(getUserTexts([...result.tail, ...result.head])).toEqual(["still sending"]);
|
||||
});
|
||||
|
||||
it("preserves the canonical end cursor on a projected assistant message", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
@@ -991,13 +958,6 @@ describe("processTimelineResponse", () => {
|
||||
text: "canonical prefix continuation",
|
||||
timestamp: new Date(2001),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 101 },
|
||||
source: {
|
||||
startSeq: 100,
|
||||
chunks: [
|
||||
{ seq: 100, offset: 0 },
|
||||
{ seq: 101, offset: "canonical prefix".length },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
isInitializing: true,
|
||||
@@ -3078,7 +3038,7 @@ describe("processTimelineResponse", () => {
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
id: "assistant-message",
|
||||
id: "loaded-assistant",
|
||||
text: "projected response",
|
||||
messageId: "assistant-message",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 500 },
|
||||
@@ -3237,57 +3197,6 @@ describe("processTimelineResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["older", "newer", ["older chunk ", "newer chunk"]],
|
||||
["same", "same", ["older chunk newer chunk"]],
|
||||
[undefined, "newer", ["older chunk newer chunk"]],
|
||||
["older", undefined, ["older chunk newer chunk"]],
|
||||
])("preserves assistant identity at a prepend boundary (%s, %s)", (olderId, newerId, texts) => {
|
||||
const older = makeTimelineEntry(1, "older chunk ");
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [
|
||||
{
|
||||
...makeAssistantItem("newer chunk", "newer"),
|
||||
messageId: newerId,
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
source: { startSeq: 2, chunks: [{ seq: 2, offset: 0 }] },
|
||||
},
|
||||
],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 2, endSeq: 2 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "before",
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 1 },
|
||||
entries: [
|
||||
{
|
||||
...older,
|
||||
item: { type: "assistant_message", text: "older chunk ", messageId: olderId },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(getAssistantTexts(result.tail)).toEqual(texts);
|
||||
expect(result.tail.map((row) => row.source)).toEqual(
|
||||
texts.length === 2
|
||||
? [
|
||||
{ startSeq: 1, chunks: [{ seq: 1, offset: 0 }] },
|
||||
{ startSeq: 2, chunks: [{ seq: 2, offset: 0 }] },
|
||||
]
|
||||
: [
|
||||
{
|
||||
startSeq: 1,
|
||||
chunks: [
|
||||
{ seq: 1, offset: 0 },
|
||||
{ seq: 2, offset: 12 },
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("coalesces tool call lifecycle rows across the older-page prepend boundary", () => {
|
||||
const callId = "toolu_boundary";
|
||||
const turnId = "turn-boundary";
|
||||
@@ -4437,13 +4346,12 @@ describe("createAgentStreamReducerQueue", () => {
|
||||
let currentHead: StreamItem[] = [];
|
||||
|
||||
const queue = createAgentStreamReducerQueue({
|
||||
apply: (agentId, events) => {
|
||||
const result = processAgentStreamEvents({
|
||||
events,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor: undefined,
|
||||
});
|
||||
getSnapshot: () => ({
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor: undefined,
|
||||
}),
|
||||
commit: (agentId, result) => {
|
||||
currentTail = result.tail;
|
||||
currentHead = result.head;
|
||||
commits.push({
|
||||
@@ -4452,6 +4360,7 @@ describe("createAgentStreamReducerQueue", () => {
|
||||
cursorEndSeq: result.cursor?.endSeq ?? null,
|
||||
});
|
||||
},
|
||||
handleSideEffects: () => {},
|
||||
scheduleFlush: scheduler.schedule,
|
||||
cancelFlush: scheduler.cancel,
|
||||
});
|
||||
@@ -4478,17 +4387,17 @@ describe("createAgentStreamReducerQueue", () => {
|
||||
const scheduler = createManualScheduler();
|
||||
const commits: string[] = [];
|
||||
const queue = createAgentStreamReducerQueue({
|
||||
apply: (agentId, events) => {
|
||||
const result = processAgentStreamEvents({
|
||||
events,
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
});
|
||||
getSnapshot: () => ({
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
}),
|
||||
commit: (agentId, result) => {
|
||||
commits.push(
|
||||
`${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`,
|
||||
);
|
||||
},
|
||||
handleSideEffects: () => {},
|
||||
scheduleFlush: scheduler.schedule,
|
||||
cancelFlush: scheduler.cancel,
|
||||
});
|
||||
@@ -4507,17 +4416,17 @@ describe("createAgentStreamReducerQueue", () => {
|
||||
let currentCursor: TimelineCursor | undefined;
|
||||
|
||||
const queue = createAgentStreamReducerQueue({
|
||||
apply: (_agentId, events) => {
|
||||
const result = processAgentStreamEvents({
|
||||
events,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
});
|
||||
getSnapshot: () => ({
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
}),
|
||||
commit: (_agentId, result) => {
|
||||
currentTail = result.tail;
|
||||
currentHead = result.head;
|
||||
currentCursor = result.cursor ?? undefined;
|
||||
},
|
||||
handleSideEffects: () => {},
|
||||
scheduleFlush: scheduler.schedule,
|
||||
cancelFlush: scheduler.cancel,
|
||||
});
|
||||
@@ -4556,17 +4465,17 @@ describe("createAgentStreamReducerQueue", () => {
|
||||
const scheduler = createManualScheduler();
|
||||
const commits: string[] = [];
|
||||
const queue = createAgentStreamReducerQueue({
|
||||
apply: (agentId, events) => {
|
||||
const result = processAgentStreamEvents({
|
||||
events,
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
});
|
||||
getSnapshot: () => ({
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
}),
|
||||
commit: (agentId, result) => {
|
||||
commits.push(
|
||||
`${agentId}:${result.head[0]?.kind === "assistant_message" ? result.head[0].text : ""}`,
|
||||
);
|
||||
},
|
||||
handleSideEffects: () => {},
|
||||
scheduleFlush: scheduler.schedule,
|
||||
cancelFlush: scheduler.cancel,
|
||||
});
|
||||
@@ -4578,706 +4487,3 @@ describe("createAgentStreamReducerQueue", () => {
|
||||
expect(scheduler.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forward pages reconcile live rows by identity and source position", () => {
|
||||
const detail: ToolCallDetail = { type: "read", filePath: "/tmp/example.ts" };
|
||||
const checkpoint = (n: number) => `\n\n---\n\nReplica QA checkpoint ${n}: saved history`;
|
||||
|
||||
function liveBeyondBootstrap() {
|
||||
return processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("\n\n---\n\n", "msg-102"), 675),
|
||||
makeStreamReducerEvent(
|
||||
makeAssistantTimelineEvent("Replica QA checkpoint 102: saved history", "msg-102"),
|
||||
679,
|
||||
),
|
||||
makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 681),
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("\n\n---\n\n", "msg-103"), 682),
|
||||
makeStreamReducerEvent(
|
||||
makeAssistantTimelineEvent("Replica QA checkpoint 103: saved history", "msg-103"),
|
||||
685,
|
||||
),
|
||||
makeStreamReducerEvent(makeToolCallTimelineEvent("exec-2"), 687),
|
||||
],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
}
|
||||
|
||||
function projectRows(items: StreamItem[]) {
|
||||
return items.map((item) =>
|
||||
item.kind === "assistant_message"
|
||||
? { kind: item.kind, messageId: item.messageId, text: item.text }
|
||||
: { kind: item.kind, id: item.id },
|
||||
);
|
||||
}
|
||||
|
||||
it("replaces split live blocks wholly beyond the cursor instead of appending them again", () => {
|
||||
const live = liveBeyondBootstrap();
|
||||
expect(getAssistantTexts(live.head)).toHaveLength(4);
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [
|
||||
{
|
||||
...makeAssistantItem(checkpoint(101), "msg-101"),
|
||||
messageId: "msg-101",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 672 },
|
||||
},
|
||||
],
|
||||
currentHead: live.head,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 691 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(675, checkpoint(102), "assistant_message", 679),
|
||||
sourceSeqRanges: [{ startSeq: 675, endSeq: 679 }],
|
||||
item: { type: "assistant_message", text: checkpoint(102), messageId: "msg-102" },
|
||||
},
|
||||
makeToolCallTimelineEntry(681, "exec-1", "completed", detail),
|
||||
{
|
||||
...makeTimelineEntry(682, checkpoint(103), "assistant_message", 685),
|
||||
sourceSeqRanges: [{ startSeq: 682, endSeq: 685 }],
|
||||
item: { type: "assistant_message", text: checkpoint(103), messageId: "msg-103" },
|
||||
},
|
||||
makeToolCallTimelineEntry(687, "exec-2", "completed", detail),
|
||||
{
|
||||
...makeTimelineEntry(691, checkpoint(104)),
|
||||
item: { type: "assistant_message", text: checkpoint(104), messageId: "msg-104" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const rows = [...result.tail, ...result.head];
|
||||
expect(projectRows(rows)).toEqual([
|
||||
{ kind: "assistant_message", messageId: "msg-101", text: checkpoint(101) },
|
||||
{ kind: "assistant_message", messageId: "msg-102", text: checkpoint(102) },
|
||||
{ kind: "tool_call", id: "agent_tool_exec-1" },
|
||||
{ kind: "assistant_message", messageId: "msg-103", text: checkpoint(103) },
|
||||
{ kind: "tool_call", id: "agent_tool_exec-2" },
|
||||
{ kind: "assistant_message", messageId: "msg-104", text: checkpoint(104) },
|
||||
]);
|
||||
expect(new Set(rows.map((item) => item.id)).size).toBe(rows.length);
|
||||
expect(getAgentToolCalls(rows).map((item) => item.payload.data.status)).toEqual([
|
||||
"completed",
|
||||
"completed",
|
||||
]);
|
||||
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 600, endSeq: 691 });
|
||||
});
|
||||
|
||||
it.each(["positioned", "unpositioned", "repeated"] as const)(
|
||||
"preserves same-message segments around a tool (%s)",
|
||||
(delivery) => {
|
||||
const beforeText = delivery === "repeated" ? "Repeat" : "Before tool";
|
||||
const afterText = delivery === "repeated" ? "Repeat" : "After tool";
|
||||
const live = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent(beforeText, "msg-a"), 675),
|
||||
makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676),
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent(afterText, "msg-a"), 677),
|
||||
].map((event) =>
|
||||
delivery === "positioned" || (delivery === "repeated" && event.seq === 676)
|
||||
? event
|
||||
: Object.assign(event, { seq: undefined, epoch: undefined }),
|
||||
),
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: live.tail,
|
||||
currentHead: live.head,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
window: { minSeq: 1, maxSeq: 677, nextSeq: 678 },
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 677 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(675, beforeText),
|
||||
item: { type: "assistant_message", text: beforeText, messageId: "msg-a" },
|
||||
},
|
||||
makeToolCallTimelineEntry(676, "exec-1", "running", detail),
|
||||
{
|
||||
...makeTimelineEntry(677, afterText),
|
||||
item: { type: "assistant_message", text: afterText, messageId: "msg-a" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const ids = [...result.tail, ...result.head].map((row) => row.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(projectRows([...result.tail, ...result.head])).toEqual([
|
||||
{ kind: "assistant_message", messageId: "msg-a", text: beforeText },
|
||||
{ kind: "tool_call", id: "agent_tool_exec-1" },
|
||||
{ kind: "assistant_message", messageId: "msg-a", text: afterText },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("records each unit's first source position on bootstrap", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "tail",
|
||||
reset: true,
|
||||
window: { minSeq: 1, maxSeq: 700, nextSeq: 701 },
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 700 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(675, "Before tool"),
|
||||
item: { type: "assistant_message", text: "Before tool", messageId: "msg-a" },
|
||||
},
|
||||
{
|
||||
...makeToolCallTimelineEntry(676, "exec-1", "completed", detail),
|
||||
seqEnd: 700,
|
||||
sourceSeqRanges: [
|
||||
{ startSeq: 676, endSeq: 676 },
|
||||
{ startSeq: 700, endSeq: 700 },
|
||||
],
|
||||
collapsed: ["tool_lifecycle"],
|
||||
},
|
||||
{
|
||||
...makeTimelineEntry(677, "After tool"),
|
||||
item: { type: "assistant_message", text: "After tool", messageId: "msg-a" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const rows = [...result.tail, ...result.head];
|
||||
expect(rows.map((row) => row.kind)).toEqual([
|
||||
"assistant_message",
|
||||
"tool_call",
|
||||
"assistant_message",
|
||||
]);
|
||||
expect(rows.map(rowStartSeq)).toEqual([675, 676, 677]);
|
||||
});
|
||||
|
||||
it("keeps parallel tools in the order they started when their completions are reversed", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "tail",
|
||||
reset: true,
|
||||
window: { minSeq: 1, maxSeq: 700, nextSeq: 701 },
|
||||
startCursor: { seq: 676 },
|
||||
endCursor: { seq: 700 },
|
||||
entries: [
|
||||
{
|
||||
...makeToolCallTimelineEntry(676, "first", "completed", detail),
|
||||
seqEnd: 700,
|
||||
sourceSeqRanges: [
|
||||
{ startSeq: 676, endSeq: 676 },
|
||||
{ startSeq: 700, endSeq: 700 },
|
||||
],
|
||||
collapsed: ["tool_lifecycle"],
|
||||
},
|
||||
{
|
||||
...makeToolCallTimelineEntry(677, "second", "completed", detail),
|
||||
seqEnd: 690,
|
||||
sourceSeqRanges: [
|
||||
{ startSeq: 677, endSeq: 677 },
|
||||
{ startSeq: 690, endSeq: 690 },
|
||||
],
|
||||
collapsed: ["tool_lifecycle"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const tools = getAgentToolCalls([...result.tail, ...result.head]);
|
||||
expect(tools.map((tool) => tool.payload.data.callId)).toEqual(["first", "second"]);
|
||||
expect(tools.map(rowStartSeq)).toEqual([676, 677]);
|
||||
expect(tools.map((tool) => tool.timelineCursor?.seq)).toEqual([700, 690]);
|
||||
});
|
||||
|
||||
it("keeps an interleaved tool in place when the page arrives with no live rows", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
window: { minSeq: 1, maxSeq: 700, nextSeq: 701 },
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 700 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(675, "Before tool"),
|
||||
item: { type: "assistant_message", text: "Before tool", messageId: "msg-a" },
|
||||
},
|
||||
{
|
||||
...makeToolCallTimelineEntry(676, "exec-1", "completed", detail),
|
||||
seqEnd: 700,
|
||||
sourceSeqRanges: [
|
||||
{ startSeq: 676, endSeq: 676 },
|
||||
{ startSeq: 700, endSeq: 700 },
|
||||
],
|
||||
collapsed: ["tool_lifecycle"],
|
||||
},
|
||||
{
|
||||
...makeTimelineEntry(677, "After tool"),
|
||||
item: { type: "assistant_message", text: "After tool", messageId: "msg-a" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const rows = [...result.tail, ...result.head];
|
||||
expect(projectRows(rows)).toEqual([
|
||||
{ kind: "assistant_message", messageId: "msg-a", text: "Before tool" },
|
||||
{ kind: "tool_call", id: "agent_tool_exec-1" },
|
||||
{ kind: "assistant_message", messageId: "msg-a", text: "After tool" },
|
||||
]);
|
||||
expect(rows.map(rowStartSeq)).toEqual([675, 676, 677]);
|
||||
});
|
||||
|
||||
it("keeps an interleaved tool whose completion is newer than text on both sides", () => {
|
||||
const live = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("Before tool", "msg-a"), 675),
|
||||
makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676),
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("After tool", "msg-a"), 677),
|
||||
makeStreamReducerEvent(
|
||||
{
|
||||
...makeToolCallTimelineEvent("exec-1"),
|
||||
item: makeToolCallTimelineEntry(676, "exec-1", "completed", detail).item,
|
||||
} as AgentStreamEventPayload,
|
||||
700,
|
||||
),
|
||||
],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: live.tail,
|
||||
currentHead: live.head,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
window: { minSeq: 1, maxSeq: 700, nextSeq: 701 },
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 700 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(675, "Before tool"),
|
||||
item: { type: "assistant_message", text: "Before tool", messageId: "msg-a" },
|
||||
},
|
||||
{
|
||||
...makeToolCallTimelineEntry(676, "exec-1", "completed", detail),
|
||||
seqEnd: 700,
|
||||
sourceSeqRanges: [
|
||||
{ startSeq: 676, endSeq: 676 },
|
||||
{ startSeq: 700, endSeq: 700 },
|
||||
],
|
||||
collapsed: ["tool_lifecycle"],
|
||||
},
|
||||
{
|
||||
...makeTimelineEntry(677, "After tool"),
|
||||
item: { type: "assistant_message", text: "After tool", messageId: "msg-a" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const rows = [...result.tail, ...result.head];
|
||||
expect(projectRows(rows)).toEqual([
|
||||
{ kind: "assistant_message", messageId: "msg-a", text: "Before tool" },
|
||||
{ kind: "tool_call", id: "agent_tool_exec-1" },
|
||||
{ kind: "assistant_message", messageId: "msg-a", text: "After tool" },
|
||||
]);
|
||||
expect(getAgentToolCalls(rows)[0]?.payload.data.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("keeps a tool call at the position it appeared even when its completion is newer", () => {
|
||||
const live = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676),
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("While the tool runs", "msg-a"), 680),
|
||||
makeStreamReducerEvent(
|
||||
{
|
||||
...makeToolCallTimelineEvent("exec-1"),
|
||||
item: { ...makeToolCallTimelineEntry(676, "exec-1", "completed", detail).item },
|
||||
} as AgentStreamEventPayload,
|
||||
700,
|
||||
),
|
||||
],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: live.head,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
window: { minSeq: 1, maxSeq: 700, nextSeq: 701 },
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 700 },
|
||||
entries: [
|
||||
{
|
||||
...makeToolCallTimelineEntry(676, "exec-1", "completed", detail),
|
||||
seqEnd: 700,
|
||||
sourceSeqRanges: [
|
||||
{ startSeq: 676, endSeq: 676 },
|
||||
{ startSeq: 700, endSeq: 700 },
|
||||
],
|
||||
collapsed: ["tool_lifecycle"],
|
||||
},
|
||||
{
|
||||
...makeTimelineEntry(680, "While the tool runs"),
|
||||
item: { type: "assistant_message", text: "While the tool runs", messageId: "msg-a" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(projectRows([...result.tail, ...result.head])).toEqual([
|
||||
{ kind: "tool_call", id: "agent_tool_exec-1" },
|
||||
{ kind: "assistant_message", messageId: "msg-a", text: "While the tool runs" },
|
||||
]);
|
||||
expect(getAgentToolCalls([...result.tail, ...result.head])[0]?.payload.data.status).toBe(
|
||||
"completed",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate a notification the page delivers again", () => {
|
||||
const live = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "error", message: "Rate limited" },
|
||||
} as AgentStreamEventPayload,
|
||||
680,
|
||||
),
|
||||
],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: live.head,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
window: { minSeq: 1, maxSeq: 680, nextSeq: 681 },
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 680 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(680, "Rate limited"),
|
||||
item: { type: "error", message: "Rate limited" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const notifications = [...result.tail, ...result.head].filter(
|
||||
(item) => item.kind === "notification",
|
||||
);
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]?.id).toBe(live.head[0]?.id);
|
||||
});
|
||||
|
||||
it("inserts missed canonical history before newer live messages during reconnect", () => {
|
||||
const live = processAgentStreamEvents({
|
||||
events: [makeStreamReducerEvent(makeAssistantTimelineEvent("Arrived live", "msg-new"), 700)],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [],
|
||||
currentHead: live.head,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
window: { minSeq: 1, maxSeq: 690, nextSeq: 691 },
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 690 },
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(675, "Missed while closed", "assistant_message", 679),
|
||||
item: { type: "assistant_message", text: "Missed while closed", messageId: "msg-old" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([
|
||||
"Missed while closed",
|
||||
"Arrived live",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "partially overlaps the canonical prefix",
|
||||
events: [
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("llo", "msg-a"), 680),
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent(" world", "msg-a"), 700),
|
||||
],
|
||||
canonical: "Hello",
|
||||
expected: "Hello world",
|
||||
},
|
||||
{
|
||||
name: "repeats the prefix after its coverage",
|
||||
events: [makeStreamReducerEvent(makeAssistantTimelineEvent("Ha", "msg-a"), 700)],
|
||||
canonical: "Ha",
|
||||
expected: "HaHa",
|
||||
},
|
||||
])(
|
||||
"reconciles by source coverage when newer live text $name",
|
||||
({ events, canonical, expected }) => {
|
||||
const live = processAgentStreamEvents({
|
||||
events,
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: live.head,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
window: { minSeq: 1, maxSeq: 690, nextSeq: 691 },
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 690 },
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(675, canonical, "assistant_message", 690),
|
||||
item: { type: "assistant_message", text: canonical, messageId: "msg-a" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([expected]);
|
||||
},
|
||||
);
|
||||
|
||||
it("fills a missing prefix when a lagging final page meets a newer live suffix", () => {
|
||||
const live = processAgentStreamEvents({
|
||||
events: [makeStreamReducerEvent(makeAssistantTimelineEvent(" world", "msg-a"), 700)],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: live.head,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 690 },
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(675, "Hello", "assistant_message", 690),
|
||||
item: { type: "assistant_message", text: "Hello", messageId: "msg-a" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["Hello world"]);
|
||||
});
|
||||
|
||||
it("prefixes only the first block of a split live suffix", () => {
|
||||
const live = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent(" world.\n\n", "msg-a"), 700),
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("Second paragraph", "msg-a"), 701),
|
||||
],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
expect(getAssistantTexts(live.head)).toEqual([" world.", "Second paragraph"]);
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: live.head,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 690 },
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(675, "Hello", "assistant_message", 690),
|
||||
item: { type: "assistant_message", text: "Hello", messageId: "msg-a" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const rows = [...result.tail, ...result.head].filter(
|
||||
(item) => item.kind === "assistant_message",
|
||||
);
|
||||
expect(rows.map((item) => item.text)).toEqual(["Hello world.", "Second paragraph"]);
|
||||
expect(rows.map((item) => item.id)).toEqual(live.head.map((item) => item.id));
|
||||
expect(rows.every((item) => item.messageId === "msg-a")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([true, false])("keeps newer text beyond a lagging page (positioned=%s)", (positioned) => {
|
||||
const live = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("Hello", "msg-a"), 690),
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent(" world", "msg-a"), 700),
|
||||
].map((event) =>
|
||||
positioned ? event : Object.assign(event, { seq: undefined, epoch: undefined }),
|
||||
),
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: live.head,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 690 },
|
||||
hasNewer: true,
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(690, "Hello"),
|
||||
item: { type: "assistant_message", text: "Hello", messageId: "msg-a" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const assistants = [...result.tail, ...result.head].filter(
|
||||
(item) => item.kind === "assistant_message",
|
||||
);
|
||||
expect(assistants).toHaveLength(1);
|
||||
expect(assistants[0]).toMatchObject({
|
||||
messageId: "msg-a",
|
||||
text: "Hello world",
|
||||
});
|
||||
expect(assistants[0]?.timelineCursor).toEqual(
|
||||
positioned ? { epoch: "epoch-1", seq: 700 } : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"keeps distinct identities with identical text (positioned=%s)",
|
||||
(positioned) => {
|
||||
const live = processAgentStreamEvents({
|
||||
events: [makeStreamReducerEvent(makeAssistantTimelineEvent("Done.", "msg-a"), 680)].map(
|
||||
(event) =>
|
||||
positioned ? event : Object.assign(event, { seq: undefined, epoch: undefined }),
|
||||
),
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: live.head,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 681 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(681, "Done."),
|
||||
item: { type: "assistant_message", text: "Done.", messageId: "msg-b" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
[...result.tail, ...result.head]
|
||||
.filter((item) => item.kind === "assistant_message")
|
||||
.map((item) => item.messageId),
|
||||
).toEqual(["msg-a", "msg-b"]);
|
||||
},
|
||||
);
|
||||
it("does not guess which identical unpositioned segment a partial page covers", () => {
|
||||
const live = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("Repeat", "msg-a"), 675),
|
||||
makeStreamReducerEvent(makeToolCallTimelineEvent("exec-1"), 676),
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("Repeat", "msg-a"), 677),
|
||||
].map((event) => Object.assign(event, { seq: undefined, epoch: undefined })),
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
hasAuthoritativeBaseline: false,
|
||||
});
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: live.tail,
|
||||
currentHead: live.head,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 600, endSeq: 674 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startCursor: { seq: 675 },
|
||||
endCursor: { seq: 675 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(675, "Repeat"),
|
||||
item: { type: "assistant_message", text: "Repeat", messageId: "msg-a" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const rows = [...result.tail, ...result.head];
|
||||
expect(rows.filter((row) => !row.timelineCursor)).toEqual([...live.tail, ...live.head]);
|
||||
expect(rows.filter((row) => row.timelineCursor).map((row) => rowStartSeq(row))).toEqual([675]);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
|
||||
import { createViewedTimelineSync } from "./viewed-timeline-sync";
|
||||
import {
|
||||
consumeForcedTimelineTailReplacement,
|
||||
createViewedTimelineSync,
|
||||
type TimelineResponsePayload,
|
||||
} from "./viewed-timeline-sync";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
@@ -27,7 +31,7 @@ interface MembershipRequest {
|
||||
interface TimelineFetch {
|
||||
agentId: string;
|
||||
request: ProjectedTimelineForwardFetchPlan;
|
||||
respond(input: { hasNewer: boolean; seq?: number | null }): void;
|
||||
respond(input: { hasNewer: boolean; seq?: number }): void;
|
||||
fail(message: string): void;
|
||||
}
|
||||
|
||||
@@ -35,6 +39,7 @@ class TimelineWorld {
|
||||
readonly errors: string[] = [];
|
||||
readonly cursors = new Map<string, { epoch: string; endSeq: number }>();
|
||||
readonly cacheRequests: string[] = [];
|
||||
readonly forcedTimelineTailReplacements = new Set<string>();
|
||||
cacheGate: Deferred<void> | null = null;
|
||||
readonly sync = createViewedTimelineSync({
|
||||
replaceDemandedAgentIds: () => undefined,
|
||||
@@ -66,7 +71,7 @@ class TimelineWorld {
|
||||
respond: ({ hasNewer, seq = 1 }) =>
|
||||
result.resolve({
|
||||
hasNewer,
|
||||
endCursor: seq === null ? null : { epoch: `epoch-${agentId}`, seq },
|
||||
endCursor: { epoch: `epoch-${agentId}`, seq },
|
||||
}),
|
||||
fail: (message) => result.reject(new Error(message)),
|
||||
});
|
||||
@@ -74,11 +79,16 @@ class TimelineWorld {
|
||||
return result.promise;
|
||||
},
|
||||
fetchLatestTail: async (agentId) => {
|
||||
return await this.fetchTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
});
|
||||
this.forcedTimelineTailReplacements.add(agentId);
|
||||
try {
|
||||
return await this.fetchTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
});
|
||||
} finally {
|
||||
this.forcedTimelineTailReplacements.delete(agentId);
|
||||
}
|
||||
},
|
||||
reportError: (error) => {
|
||||
this.errors.push(error instanceof Error ? error.message : String(error));
|
||||
@@ -120,6 +130,10 @@ class TimelineWorld {
|
||||
return this.fetches.length;
|
||||
}
|
||||
|
||||
applyTimelineResponse(payload: TimelineResponsePayload): TimelineResponsePayload {
|
||||
return consumeForcedTimelineTailReplacement(payload, this.forcedTimelineTailReplacements);
|
||||
}
|
||||
|
||||
nextCacheRequest(): Promise<string> {
|
||||
const request = this.cacheRequests.at(-1);
|
||||
if (request) return Promise.resolve(request);
|
||||
@@ -138,10 +152,7 @@ class TimelineWorld {
|
||||
agentId,
|
||||
request,
|
||||
respond: ({ hasNewer, seq = 1 }) =>
|
||||
result.resolve({
|
||||
hasNewer,
|
||||
endCursor: seq === null ? null : { epoch: `epoch-${agentId}`, seq },
|
||||
}),
|
||||
result.resolve({ hasNewer, endCursor: { epoch: `epoch-${agentId}`, seq } }),
|
||||
fail: (message) => result.reject(new Error(message)),
|
||||
});
|
||||
this.releaseFetchWaiters();
|
||||
@@ -211,7 +222,7 @@ class TimelineWorld {
|
||||
test("uses a tail fetch when an agent becomes visible", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
@@ -224,7 +235,7 @@ test("loads the cache before choosing the authoritative network request", async
|
||||
const world = new TimelineWorld();
|
||||
world.cacheGate = deferred<void>();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const cacheRequest = await world.nextCacheRequest();
|
||||
@@ -245,22 +256,11 @@ test("loads the cache before choosing the authoritative network request", async
|
||||
fetch.respond({ hasNewer: false });
|
||||
});
|
||||
|
||||
test("a fresh visible agent lifetime performs a fresh cache preparation", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
await vi.waitFor(() => expect(world.cacheRequests).toEqual(["agent-a"]));
|
||||
|
||||
world.sync.endAgentLifetime("agent-a");
|
||||
world.sync.beginAgentLifetime("agent-a");
|
||||
|
||||
await vi.waitFor(() => expect(world.cacheRequests).toEqual(["agent-a", "agent-a"]));
|
||||
});
|
||||
|
||||
test("catches up after the restored cursor when an agent becomes visible", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
@@ -278,7 +278,7 @@ test("falls back to the latest tail when a restored cursor has more than one cat
|
||||
const world = new TimelineWorld();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
@@ -288,168 +288,63 @@ test("falls back to the latest tail when a restored cursor has more than one cat
|
||||
|
||||
const fallback = await world.nextFetch("agent-a");
|
||||
expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" });
|
||||
expect(
|
||||
world.applyTimelineResponse({
|
||||
requestId: "fallback-tail",
|
||||
agentId: "agent-a",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-agent-a",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 43, maxSeq: 82, nextSeq: 83 },
|
||||
startCursor: { epoch: "epoch-agent-a", seq: 43 },
|
||||
endCursor: { epoch: "epoch-agent-a", seq: 82 },
|
||||
hasOlder: true,
|
||||
hasNewer: false,
|
||||
entries: [],
|
||||
error: null,
|
||||
}).reset,
|
||||
).toBe(true);
|
||||
fallback.respond({ hasNewer: false });
|
||||
});
|
||||
|
||||
test("a gap absorbed by a running tail is recovered after the tail completes", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const tail = await world.nextFetch("agent-a");
|
||||
|
||||
// A live row at 12 could not be applied while the tail was in flight.
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 }, 12);
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 });
|
||||
|
||||
world.expectNoPendingFetch();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
tail.respond({ hasNewer: false, seq: 10 });
|
||||
tail.respond({ hasNewer: false });
|
||||
|
||||
// The tail settled coverage at 10; the owner continues from there, not from the stale gap cursor.
|
||||
const recovery = await world.nextFetch("agent-a");
|
||||
expect(recovery.request).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-agent-a", seq: 10 },
|
||||
cursor: { epoch: "epoch-agent-a", seq: 9 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
});
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 12 });
|
||||
recovery.respond({ hasNewer: false, seq: 12 });
|
||||
recovery.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("a page that predates observed activity does not complete the catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const initial = await world.nextFetch("agent-a");
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
initial.respond({ hasNewer: false, seq: 10 });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 15);
|
||||
const page = await world.nextFetch("agent-a");
|
||||
expect(page.request).toMatchObject({ direction: "after", cursor: { seq: 10 } });
|
||||
// More live rows arrive while that page is in transit; the page was built before them.
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20);
|
||||
world.expectNoPendingFetch();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 15 });
|
||||
page.respond({ hasNewer: false, seq: 15 });
|
||||
|
||||
const rest = await world.nextFetch("agent-a");
|
||||
expect(rest.request).toMatchObject({ direction: "after", cursor: { seq: 15 } });
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 20 });
|
||||
rest.respond({ hasNewer: false, seq: 20 });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test.each([null, 10])(
|
||||
"activity learned during a non-advancing request is fetched immediately without a sync error (end=%s)",
|
||||
async (end) => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
(await world.nextMembership()).succeed();
|
||||
const initial = await world.nextFetch("agent-a");
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
initial.respond({ hasNewer: false, seq: 10 });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 15);
|
||||
const old = await world.nextFetch("agent-a");
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 25);
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20);
|
||||
old.respond({ hasNewer: false, seq: end });
|
||||
const fresh = await world.nextFetch("agent-a");
|
||||
expect(world.errors).toEqual([]);
|
||||
expect(fresh.request).toMatchObject({ direction: "after", cursor: { seq: 10 } });
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 25 });
|
||||
fresh.respond({ hasNewer: false, seq: 25 });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
world.expectNoPendingFetch();
|
||||
world.sync.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test("no progress against an obligation already known at request start retains it with bounded backoff", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const initial = await world.nextFetch("agent-a");
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
initial.respond({ hasNewer: false, seq: 10 });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20);
|
||||
const page = await world.nextFetch("agent-a");
|
||||
page.respond({ hasNewer: false, seq: 10 });
|
||||
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"));
|
||||
expect(world.errors).toEqual([
|
||||
"Timeline coverage for agent-a has not reached observed sequence 20",
|
||||
]);
|
||||
world.expectNoPendingFetch();
|
||||
const retry = await world.nextRetry(1000);
|
||||
retry();
|
||||
const fresh = await world.nextFetch("agent-a");
|
||||
expect(fresh.request).toMatchObject({ direction: "after", cursor: { seq: 10 } });
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 20 });
|
||||
fresh.respond({ hasNewer: false, seq: 20 });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
world.sync.dispose();
|
||||
});
|
||||
|
||||
test("a daemon claiming another page without advancing cannot cause a busy loop", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
(await world.nextMembership()).succeed();
|
||||
const first = await world.nextFetch("agent-a");
|
||||
first.respond({ hasNewer: true, seq: 10 });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"));
|
||||
expect(world.errors).toEqual(["Timeline page for agent-a did not advance its cursor"]);
|
||||
world.expectNoPendingFetch();
|
||||
await world.nextRetry(1000);
|
||||
world.sync.dispose();
|
||||
});
|
||||
|
||||
test("an observed head from a previous epoch does not extend a catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const initial = await world.nextFetch("agent-a");
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
initial.respond({ hasNewer: false, seq: 10 });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 20);
|
||||
const page = await world.nextFetch("agent-a");
|
||||
world.cursors.set("agent-a", { epoch: "epoch-next", endSeq: 3 });
|
||||
page.respond({ hasNewer: false, seq: 3 });
|
||||
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("unchanged visible-set publication does not cancel paged catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const firstPage = await world.nextFetch("agent-a");
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a", "agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]);
|
||||
firstPage.respond({ hasNewer: true, seq: 5 });
|
||||
const secondPage = await world.nextFetch("agent-a");
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||
@@ -471,7 +366,7 @@ test("unchanged visible-set publication does not cancel paged catch-up", async (
|
||||
test("all acknowledged agents begin catch-up independently", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-b", "agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b", "agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
@@ -488,7 +383,7 @@ test("all acknowledged agents begin catch-up independently", async () => {
|
||||
test("an eviction during acknowledgement never catches up the stale hot set", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", [
|
||||
world.sync.replaceVisibleAgentIds("workspace", [
|
||||
"agent-a",
|
||||
"agent-b",
|
||||
"agent-c",
|
||||
@@ -496,7 +391,7 @@ test("an eviction during acknowledgement never catches up the stale hot set", as
|
||||
"agent-e",
|
||||
]);
|
||||
const staleMembership = await world.nextMembership();
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-f"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]);
|
||||
staleMembership.succeed();
|
||||
const currentMembership = await world.nextMembership();
|
||||
currentMembership.succeed();
|
||||
@@ -517,7 +412,7 @@ test("an eviction during acknowledgement never catches up the stale hot set", as
|
||||
test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const firstMembership = await world.nextMembership();
|
||||
firstMembership.succeed();
|
||||
const stalePage = await world.nextFetch("agent-a");
|
||||
@@ -539,8 +434,8 @@ test("disconnect cancels paging and reconnect restores membership before fresh c
|
||||
|
||||
test("navigation while disconnected reconnects only the currently visible agent", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
|
||||
world.sync.setConnected(true);
|
||||
const membership = await world.nextMembership();
|
||||
@@ -554,8 +449,8 @@ test("navigation while disconnected reconnects only the currently visible agent"
|
||||
|
||||
test("overlapping sources deduplicate membership and retain hidden hot agents", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.registerVisibleAgentIds("left-route", ["agent-a"]);
|
||||
world.sync.registerVisibleAgentIds("right-route", ["agent-a", "agent-b"]);
|
||||
world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]);
|
||||
world.sync.setConnected(true);
|
||||
const combined = await world.nextMembership();
|
||||
combined.succeed();
|
||||
@@ -566,37 +461,19 @@ test("overlapping sources deduplicate membership and retain hidden hot agents",
|
||||
agentA.respond({ hasNewer: false });
|
||||
agentB.respond({ hasNewer: false });
|
||||
|
||||
world.sync.registerVisibleAgentIds("left-route", []);
|
||||
world.sync.replaceVisibleAgentIds("left-route", []);
|
||||
world.expectNoPendingMembership();
|
||||
world.sync.registerVisibleAgentIds("right-route", ["agent-b"]);
|
||||
world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]);
|
||||
|
||||
expect(combined.agentIds).toEqual(["agent-a", "agent-b"]);
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("a replacement screen declaration survives obsolete cleanup and clears on final unmount", async () => {
|
||||
const world = new TimelineWorld();
|
||||
const initial = world.sync.registerVisibleAgentIds("host:workspace", ["agent-a"]);
|
||||
const replacement = world.sync.registerVisibleAgentIds("host:workspace", ["agent-b"]);
|
||||
|
||||
initial.dispose();
|
||||
replacement.replace(["agent-c"]);
|
||||
world.sync.setConnected(true);
|
||||
const membership = await world.nextMembership();
|
||||
expect(membership.agentIds).toEqual(["agent-c"]);
|
||||
membership.succeed();
|
||||
|
||||
replacement.dispose();
|
||||
replacement.replace(["agent-d"]);
|
||||
world.expectNoPendingMembership();
|
||||
expect(world.cacheRequests).not.toContain("agent-d");
|
||||
});
|
||||
|
||||
test("a failed catch-up reports once and retries through the explicit retry policy", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const failed = await world.nextFetch("agent-a");
|
||||
@@ -620,7 +497,7 @@ test("a failed catch-up reports once and retries through the explicit retry poli
|
||||
test("a failed catch-up retries with exponential backoff", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
@@ -649,7 +526,7 @@ test("a failed catch-up retries with exponential backoff", async () => {
|
||||
test("manual retries can immediately re-attempt a failed catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
@@ -669,7 +546,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () =>
|
||||
test("redeclaring unchanged visibility does not bypass catch-up backoff", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
@@ -677,7 +554,7 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async
|
||||
failed.fail("timeline unavailable");
|
||||
await world.nextRetry();
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
@@ -687,7 +564,7 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async
|
||||
test("a manual retry that fails returns to the error state", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
@@ -706,15 +583,14 @@ test("a manual retry that fails returns to the error state", async () => {
|
||||
test("gap recovery supersedes completed catch-up and pages through the current tail", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const initial = await world.nextFetch("agent-a");
|
||||
initial.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 12);
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
const gapPage = await world.nextFetch("agent-a");
|
||||
gapPage.respond({ hasNewer: true, seq: 15 });
|
||||
const finalPage = await world.nextFetch("agent-a");
|
||||
@@ -739,7 +615,7 @@ test("gap recovery supersedes completed catch-up and pages through the current t
|
||||
test("repeated recovery for the same running gap reuses the in-flight fetch", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const initial = await world.nextFetch("agent-a");
|
||||
@@ -758,7 +634,7 @@ test("repeated recovery for the same running gap reuses the in-flight fetch", as
|
||||
test("membership failure autonomously retries without another visibility declaration", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const failed = await world.nextMembership();
|
||||
failed.fail("subscription unavailable");
|
||||
const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]);
|
||||
@@ -782,7 +658,7 @@ test("membership failure autonomously retries without another visibility declara
|
||||
test("membership failures retry with exponential backoff", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
|
||||
const first = await world.nextMembership();
|
||||
first.fail("subscription unavailable");
|
||||
@@ -810,13 +686,13 @@ test("membership failures retry with exponential backoff", async () => {
|
||||
test("redeclaring unchanged visibility does not bypass membership backoff", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
|
||||
const failed = await world.nextMembership();
|
||||
failed.fail("subscription unavailable");
|
||||
await world.nextRetry();
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
@@ -826,13 +702,13 @@ test("redeclaring unchanged visibility does not bypass membership backoff", asyn
|
||||
test("backgrounding preserves the hot membership without catch-up on return", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const agentAMembership = await world.nextMembership();
|
||||
agentAMembership.succeed();
|
||||
const agentACatchUp = await world.nextFetch("agent-a");
|
||||
agentACatchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const agentBMembership = await world.nextMembership();
|
||||
agentBMembership.succeed();
|
||||
const agentBCatchUp = await world.nextFetch("agent-b");
|
||||
@@ -851,12 +727,12 @@ test("backgrounding preserves the hot membership without catch-up on return", as
|
||||
test("stale membership retry cannot overwrite a newer effective set", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const failed = await world.nextMembership();
|
||||
failed.fail("subscription unavailable");
|
||||
const staleRetry = await world.nextRetry();
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const current = await world.nextMembership();
|
||||
staleRetry();
|
||||
current.succeed();
|
||||
@@ -874,7 +750,7 @@ test("stale membership retry cannot overwrite a newer effective set", async () =
|
||||
test("membership retry cannot run while disconnected", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const failed = await world.nextMembership();
|
||||
failed.fail("subscription unavailable");
|
||||
const disconnectedRetry = await world.nextRetry();
|
||||
@@ -894,13 +770,13 @@ test("membership retry cannot run while disconnected", async () => {
|
||||
test("returning to a hot hidden agent stays live after inactivity", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const initialMembership = await world.nextMembership();
|
||||
initialMembership.succeed();
|
||||
const agentACatchUp = await world.nextFetch("agent-a");
|
||||
agentACatchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const expandedMembership = await world.nextMembership();
|
||||
expandedMembership.succeed();
|
||||
const agentBCatchUp = await world.nextFetch("agent-b");
|
||||
@@ -908,7 +784,7 @@ test("returning to a hot hidden agent stays live after inactivity", async () =>
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
|
||||
|
||||
world.elapse(60_000);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
@@ -918,7 +794,7 @@ test("the hot set evicts the least-recent hidden agent and promotes revisited ag
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
for (const agentId of ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]) {
|
||||
world.sync.registerVisibleAgentIds("workspace", [agentId]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", [agentId]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch(agentId);
|
||||
@@ -926,22 +802,22 @@ test("the hot set evicts the least-recent hidden agent and promotes revisited ag
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus(agentId)).toBe("ready"));
|
||||
}
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-f"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]);
|
||||
const eviction = await world.nextMembership();
|
||||
eviction.succeed();
|
||||
const agentFCatchUp = await world.nextFetch("agent-f");
|
||||
agentFCatchUp.respond({ hasNewer: false });
|
||||
expect(eviction.agentIds).toEqual(["agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]);
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const restoreEvicted = await world.nextMembership();
|
||||
restoreEvicted.succeed();
|
||||
const agentACatchUp = await world.nextFetch("agent-a");
|
||||
@@ -953,7 +829,7 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn
|
||||
const world = new TimelineWorld();
|
||||
const visible = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f"];
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", visible);
|
||||
world.sync.replaceVisibleAgentIds("workspace", visible);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUps = await Promise.all(visible.map((agentId) => world.nextFetch(agentId)));
|
||||
@@ -961,7 +837,7 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn
|
||||
|
||||
expect(membership.agentIds).toEqual(visible);
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", visible.slice(0, 5));
|
||||
world.sync.replaceVisibleAgentIds("workspace", visible.slice(0, 5));
|
||||
const hiddenEviction = await world.nextMembership();
|
||||
hiddenEviction.succeed();
|
||||
expect(hiddenEviction.agentIds).toEqual(visible.slice(0, 5));
|
||||
@@ -970,13 +846,13 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn
|
||||
test("disconnect clears hidden hot agents before reconnecting the visible set", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const agentAMembership = await world.nextMembership();
|
||||
agentAMembership.succeed();
|
||||
const agentACatchUp = await world.nextFetch("agent-a");
|
||||
agentACatchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const agentBMembership = await world.nextMembership();
|
||||
agentBMembership.succeed();
|
||||
const agentBCatchUp = await world.nextFetch("agent-b");
|
||||
@@ -993,7 +869,7 @@ test("disconnect clears hidden hot agents before reconnecting the visible set",
|
||||
|
||||
expect(restored.agentIds).toEqual(["agent-b"]);
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const reopened = await world.nextMembership();
|
||||
reopened.succeed();
|
||||
const reopenedCatchUp = await world.nextFetch("agent-a");
|
||||
@@ -1004,7 +880,7 @@ test("disconnect clears hidden hot agents before reconnecting the visible set",
|
||||
test("legacy delivery skips subscription RPCs while retaining visibility catch-up and gap recovery", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setDeliveryMode("legacy");
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.setConnected(true);
|
||||
|
||||
world.expectNoPendingMembership();
|
||||
@@ -1012,8 +888,7 @@ test("legacy delivery skips subscription RPCs while retaining visibility catch-u
|
||||
initial.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }, 12);
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
const recovery = await world.nextFetch("agent-a");
|
||||
recovery.respond({ hasNewer: false });
|
||||
|
||||
@@ -1029,17 +904,17 @@ test("legacy delivery catches up after returning to a view or foreground", async
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setDeliveryMode("legacy");
|
||||
world.sync.setConnected(true);
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const firstAgentA = await world.nextFetch("agent-a");
|
||||
firstAgentA.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const agentB = await world.nextFetch("agent-b");
|
||||
agentB.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
|
||||
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const secondAgentA = await world.nextFetch("agent-a");
|
||||
secondAgentA.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
@@ -1055,7 +930,7 @@ test("legacy delivery catches up after returning to a view or foreground", async
|
||||
test("switching from legacy to selective delivery publishes membership and catches up once", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setDeliveryMode("legacy");
|
||||
world.sync.registerVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.setConnected(true);
|
||||
const legacyCatchUp = await world.nextFetch("agent-a");
|
||||
legacyCatchUp.respond({ hasNewer: false });
|
||||
|
||||
@@ -1,23 +1,312 @@
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import {
|
||||
planTimelineCatchUpAfter,
|
||||
planTimelineResumeFetch,
|
||||
type ProjectedTimelineForwardFetchPlan,
|
||||
} from "./timeline-sync-plan";
|
||||
import type { CachedTimeline } from "@/runtime/replica-cache";
|
||||
import {
|
||||
createAgentStreamReducerQueue,
|
||||
scheduleAgentStreamReducerFlush,
|
||||
cancelAgentStreamReducerFlush,
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type AgentTimelineCursorState,
|
||||
type AgentTimelineState,
|
||||
} from "@/stores/session-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { getSendingClientMessageIds } from "@/composer/submission/model";
|
||||
import {
|
||||
getInitDeferred,
|
||||
getInitKey,
|
||||
rejectInitDeferred,
|
||||
resolveInitDeferred,
|
||||
} from "@/utils/agent-initialization";
|
||||
import {
|
||||
createSessionAgentStreamReducerQueue,
|
||||
type AgentStreamReducerEvent,
|
||||
type ProcessTimelineResponseOutput,
|
||||
processTimelineResponse,
|
||||
} from "./session-stream-reducers";
|
||||
import type { TimelineReplica, TimelineResponsePayload, TimelinePageRequest } from "./replica";
|
||||
import type { AgentRemovalReason } from "@/utils/agent-directory-sync";
|
||||
import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan";
|
||||
import { replaceWithCanonicalStream } from "@/types/stream";
|
||||
|
||||
export interface TimelineReplicaStorage {
|
||||
readTimeline(serverId: string, agentId: string): Promise<CachedTimeline | undefined>;
|
||||
commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void;
|
||||
}
|
||||
|
||||
async function prepareCachedTimeline(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
storage: TimelineReplicaStorage;
|
||||
prepareAgent: (agentId: string) => Promise<void>;
|
||||
}): Promise<CachedTimeline | undefined> {
|
||||
const before = useSessionStore.getState().sessions[input.serverId];
|
||||
const beforeTimeline = selectAgentTimelineState(before, input.agentId);
|
||||
if (beforeTimeline.status === "synced") return undefined;
|
||||
const [stored] = await Promise.all([
|
||||
input.storage.readTimeline(input.serverId, input.agentId),
|
||||
input.prepareAgent(input.agentId),
|
||||
]);
|
||||
if (!stored) return undefined;
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
const currentTimeline = selectAgentTimelineState(session, input.agentId);
|
||||
const currentHead = session?.agentStreamHead.get(input.agentId);
|
||||
if (currentTimeline.status === "synced") return undefined;
|
||||
if (!stored.range) {
|
||||
if (beforeTimeline.status === "painted") {
|
||||
return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items
|
||||
? stored
|
||||
: undefined;
|
||||
}
|
||||
if (currentTimeline.status !== "cold") return undefined;
|
||||
}
|
||||
const liveItems =
|
||||
currentTimeline.status === "painted"
|
||||
? [...currentTimeline.items, ...(currentHead ?? [])]
|
||||
: (currentHead ?? []);
|
||||
const replacement = replaceWithCanonicalStream({
|
||||
canonical: stored.items,
|
||||
previousTail: [],
|
||||
previousHead: liveItems,
|
||||
sendingClientMessageIds: getSendingClientMessageIds(
|
||||
session?.messageSubmissions.get(input.agentId),
|
||||
),
|
||||
preserveContinuity: true,
|
||||
// Display-only rows claim no coverage over the live head.
|
||||
canonicalCoverage: stored.range ?? {
|
||||
epoch: liveItems.find((item) => item.timelineCursor)?.timelineCursor?.epoch ?? "",
|
||||
endSeq: null,
|
||||
},
|
||||
});
|
||||
useSessionStore.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, {
|
||||
items: replacement.tail,
|
||||
head: replacement.head,
|
||||
range: stored.range,
|
||||
older: stored.hasOlder ? "available" : "none",
|
||||
newer: false,
|
||||
synchronized: false,
|
||||
acknowledgedClientMessageIds: replacement.acknowledgedClientMessageIds,
|
||||
});
|
||||
return stored;
|
||||
}
|
||||
|
||||
export interface TimelineReplica {
|
||||
prepare(agentId: string): Promise<void>;
|
||||
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
|
||||
readRange(agentId: string): AgentTimelineCursorState | undefined;
|
||||
timelineUpdated(agentId: string): void;
|
||||
}
|
||||
|
||||
class TimelineReplicaOwner implements TimelineReplica {
|
||||
private readonly cachedRanges = new Map<string, AgentTimelineCursorState>();
|
||||
private readonly preparations = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly serverId: string,
|
||||
private readonly storage: TimelineReplicaStorage,
|
||||
private readonly prepareAgent: (agentId: string) => Promise<void>,
|
||||
) {}
|
||||
|
||||
async prepare(agentId: string): Promise<void> {
|
||||
const existing = this.preparations.get(agentId);
|
||||
if (existing) return existing;
|
||||
const preparation = this.load(agentId).finally(() => {
|
||||
if (this.preparations.get(agentId) === preparation) {
|
||||
this.preparations.delete(agentId);
|
||||
}
|
||||
});
|
||||
this.preparations.set(agentId, preparation);
|
||||
return preparation;
|
||||
}
|
||||
|
||||
private async load(agentId: string): Promise<void> {
|
||||
const stored = await prepareCachedTimeline({
|
||||
serverId: this.serverId,
|
||||
agentId,
|
||||
storage: this.storage,
|
||||
prepareAgent: this.prepareAgent,
|
||||
});
|
||||
if (!stored) return;
|
||||
if (stored.range) {
|
||||
this.cachedRanges.set(agentId, stored.range);
|
||||
}
|
||||
}
|
||||
|
||||
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined {
|
||||
const range = this.cachedRanges.get(agentId);
|
||||
return range ? { epoch: range.epoch, endSeq: range.endSeq } : undefined;
|
||||
}
|
||||
|
||||
readRange(agentId: string): AgentTimelineCursorState | undefined {
|
||||
return this.cachedRanges.get(agentId);
|
||||
}
|
||||
|
||||
timelineUpdated(agentId: string): void {
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
if (timeline.status === "cold") return;
|
||||
if (timeline.status === "synced") this.cachedRanges.delete(agentId);
|
||||
this.storage.commitTimeline(this.serverId, agentId, {
|
||||
agentId,
|
||||
items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])],
|
||||
range: timeline.status === "synced" ? timeline.range : null,
|
||||
hasOlder: timeline.status === "synced" && timeline.older === "available",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createTimelineReplica(input: {
|
||||
serverId: string;
|
||||
storage: TimelineReplicaStorage;
|
||||
prepareAgent: (agentId: string) => Promise<void>;
|
||||
}): TimelineReplica {
|
||||
return new TimelineReplicaOwner(input.serverId, input.storage, input.prepareAgent);
|
||||
}
|
||||
|
||||
export interface TimelinePageResult {
|
||||
hasNewer: boolean;
|
||||
endCursor: { epoch: string; seq: number } | null;
|
||||
}
|
||||
|
||||
export type TimelineResponsePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "fetch_agent_timeline_response" }
|
||||
>["payload"];
|
||||
|
||||
export function consumeForcedTimelineTailReplacement(
|
||||
payload: TimelineResponsePayload,
|
||||
replacements: Set<string>,
|
||||
): TimelineResponsePayload {
|
||||
if (payload.direction !== "tail") return payload;
|
||||
if (!replacements.delete(payload.agentId)) return payload;
|
||||
return { ...payload, reset: true };
|
||||
}
|
||||
|
||||
function clearAgentInitializingFlag(serverId: string, agentId: string): void {
|
||||
useSessionStore.getState().setInitializingAgents(serverId, (previous) => {
|
||||
if (previous.get(agentId) !== true) return previous;
|
||||
const next = new Map(previous);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function commitProcessedTimeline(input: {
|
||||
serverId: string;
|
||||
payload: TimelineResponsePayload;
|
||||
result: ProcessTimelineResponseOutput;
|
||||
timeline: AgentTimelineState;
|
||||
currentCursor: AgentTimelineCursorState | undefined;
|
||||
synchronized: boolean;
|
||||
}): void {
|
||||
const { serverId, payload, result, timeline, currentCursor, synchronized } = input;
|
||||
const agentId = payload.agentId;
|
||||
const store = useSessionStore.getState();
|
||||
if (result.commit !== "discard") {
|
||||
store.applyAgentTimelineResponseState(serverId, agentId, {
|
||||
items: result.tail,
|
||||
head: result.head,
|
||||
range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null),
|
||||
older: result.older,
|
||||
newer:
|
||||
payload.direction === "before"
|
||||
? timeline.status === "synced" && timeline.newer === "available"
|
||||
: payload.hasNewer,
|
||||
synchronized,
|
||||
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.acknowledgedClientMessageIds.length > 0) {
|
||||
store.setAgentStreamState(serverId, agentId, {
|
||||
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
|
||||
});
|
||||
}
|
||||
if (payload.direction !== "before") {
|
||||
store.setAgentTimelineHasNewer(serverId, (current) => {
|
||||
const next = new Map(current);
|
||||
next.set(agentId, payload.hasNewer);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
store.markAgentHistorySynchronized(serverId, agentId);
|
||||
}
|
||||
|
||||
function finalizeProcessedTimeline(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
initKey: string;
|
||||
result: ProcessTimelineResponseOutput;
|
||||
synchronized: boolean;
|
||||
recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
drainQueuedAgentMessage: (agentId: string) => void;
|
||||
}): void {
|
||||
for (const effect of input.result.sideEffects) {
|
||||
if (effect.type === "catch_up") input.recoverGap(input.agentId, effect.cursor);
|
||||
}
|
||||
if (input.result.clearInitializing) {
|
||||
clearAgentInitializingFlag(input.serverId, input.agentId);
|
||||
}
|
||||
if (input.synchronized) {
|
||||
useCreateFlowStore
|
||||
.getState()
|
||||
.clearByAgent({ serverId: input.serverId, agentId: input.agentId });
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
|
||||
if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId);
|
||||
}
|
||||
if (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey);
|
||||
}
|
||||
|
||||
function applyAuthoritativeTimelineResponse(input: {
|
||||
serverId: string;
|
||||
payload: TimelineResponsePayload;
|
||||
cachedCursor?: AgentTimelineCursorState;
|
||||
recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
drainQueuedAgentMessage: (agentId: string) => void;
|
||||
}): boolean {
|
||||
const { serverId, payload } = input;
|
||||
const agentId = payload.agentId;
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
const activeInitDeferred = getInitDeferred(initKey);
|
||||
const currentCursor =
|
||||
timeline.status === "synced" ? (timeline.range ?? undefined) : input.cachedCursor;
|
||||
const result = processTimelineResponse({
|
||||
payload,
|
||||
currentTail: timeline.status === "cold" ? [] : timeline.items,
|
||||
currentHead: session?.agentStreamHead.get(agentId) ?? [],
|
||||
currentCursor,
|
||||
isInitializing: session?.initializingAgents.get(agentId) === true,
|
||||
hasActiveInitDeferred: Boolean(activeInitDeferred),
|
||||
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
|
||||
sendingClientMessageIds: getSendingClientMessageIds(session?.messageSubmissions.get(agentId)),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId);
|
||||
if (result.initResolution === "reject") rejectInitDeferred(initKey, new Error(result.error));
|
||||
return false;
|
||||
}
|
||||
|
||||
const synchronized = isTimelineResumeSnapshotAuthoritative({
|
||||
direction: payload.direction,
|
||||
hasNewer: payload.hasNewer,
|
||||
error: payload.error,
|
||||
});
|
||||
commitProcessedTimeline({ serverId, payload, result, timeline, currentCursor, synchronized });
|
||||
finalizeProcessedTimeline({
|
||||
serverId,
|
||||
agentId,
|
||||
initKey,
|
||||
result,
|
||||
synchronized,
|
||||
recoverGap: input.recoverGap,
|
||||
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface ViewedTimelineSyncPorts {
|
||||
initialDeliveryMode: TimelineDeliveryMode;
|
||||
prepare(agentId: string): Promise<void>;
|
||||
@@ -36,13 +325,8 @@ export interface ViewedTimelineSyncPorts {
|
||||
export type TimelineDeliveryMode = "legacy" | "selective";
|
||||
export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying";
|
||||
|
||||
export interface ViewedTimelineDemandDeclaration {
|
||||
replace(agentIds: string[]): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface ViewedTimelineUiBridge {
|
||||
registerVisibleAgentIds(sourceId: string, agentIds: string[]): ViewedTimelineDemandDeclaration;
|
||||
replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void;
|
||||
subscribe(listener: () => void): () => void;
|
||||
getAgentTimelineStatus(agentId: string): ViewedTimelineStatus;
|
||||
getAgentTimelineError(agentId: string): string | null;
|
||||
@@ -53,32 +337,19 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
setActive(active: boolean): void;
|
||||
setConnected(connected: boolean): void;
|
||||
setDeliveryMode(mode: TimelineDeliveryMode): void;
|
||||
recoverGap(
|
||||
agentId: string,
|
||||
cursor: { epoch: string; endSeq: number },
|
||||
observedSeq?: number,
|
||||
): void;
|
||||
beginAgentLifetime(agentId: string): void;
|
||||
endAgentLifetime(agentId: string): void;
|
||||
recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type ViewedTimelineOwnerPorts = Omit<
|
||||
ViewedTimelineSyncPorts,
|
||||
"prepare" | "replaceDemandedAgentIds" | "readCursor"
|
||||
"prepare" | "replaceDemandedAgentIds"
|
||||
>;
|
||||
|
||||
export interface ViewedTimelineOwner extends ViewedTimelineSync {
|
||||
beginTimelineRequest(
|
||||
agentId: string,
|
||||
replaceTail?: boolean,
|
||||
): (payload: TimelineResponsePayload) => void;
|
||||
applyTimelineResponse(payload: TimelineResponsePayload): void;
|
||||
enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void;
|
||||
flushStreamAgent(agentId: string): void;
|
||||
attachTransport(ports: ViewedTimelineOwnerPorts): () => void;
|
||||
acceptAgent(agentId: string): void;
|
||||
removeAgent(agentId: string, reason: AgentRemovalReason): void;
|
||||
}
|
||||
|
||||
export function createViewedTimelineOwner(input: {
|
||||
@@ -86,78 +357,33 @@ export function createViewedTimelineOwner(input: {
|
||||
replica: TimelineReplica;
|
||||
replaceDemandedAgentIds: (agentIds: string[]) => void;
|
||||
drainQueuedAgentMessage: (agentId: string) => void;
|
||||
ports?: ViewedTimelineOwnerPorts;
|
||||
ports: ViewedTimelineOwnerPorts;
|
||||
}): ViewedTimelineOwner {
|
||||
let transport = input.ports;
|
||||
const requireTransport = () => {
|
||||
if (!transport) throw new Error("Timeline transport is disconnected");
|
||||
return transport;
|
||||
};
|
||||
const sync = createViewedTimelineSync({
|
||||
initialDeliveryMode: transport?.initialDeliveryMode ?? "legacy",
|
||||
setSubscription: (ids) => requireTransport().setSubscription(ids),
|
||||
fetchPage: (id, request) => requireTransport().fetchPage(id, request),
|
||||
fetchLatestTail: (id) => requireTransport().fetchLatestTail(id),
|
||||
reportError: (error) => transport?.reportError(error),
|
||||
schedule: (task, delay) => {
|
||||
if (transport) return transport.schedule(task, delay);
|
||||
const timeout = setTimeout(task, delay);
|
||||
return () => clearTimeout(timeout);
|
||||
},
|
||||
...input.ports,
|
||||
prepare: (agentId) => input.replica.prepare(agentId),
|
||||
readCursor: (agentId) => input.replica.readCursor(agentId),
|
||||
readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId),
|
||||
replaceDemandedAgentIds: input.replaceDemandedAgentIds,
|
||||
});
|
||||
const streamQueue = createAgentStreamReducerQueue({
|
||||
apply: (agentId, events) => input.replica.applyEvents(agentId, events, sync.recoverGap),
|
||||
scheduleFlush: scheduleAgentStreamReducerFlush,
|
||||
cancelFlush: cancelAgentStreamReducerFlush,
|
||||
const streamQueue = createSessionAgentStreamReducerQueue({
|
||||
serverId: input.serverId,
|
||||
setAgentStreamState: (...args) => useSessionStore.getState().setAgentStreamState(...args),
|
||||
setAgentTimelineCursor: (...args) => useSessionStore.getState().setAgentTimelineCursor(...args),
|
||||
recoverTimelineGap: (agentId, cursor) => sync.recoverGap(agentId, cursor),
|
||||
onCommitted: (agentId) => input.replica.timelineUpdated(agentId),
|
||||
});
|
||||
function applyTimelineResponse(
|
||||
payload: TimelineResponsePayload,
|
||||
request?: TimelinePageRequest,
|
||||
): void {
|
||||
streamQueue.flushAgent(payload.agentId);
|
||||
input.replica.applyPage(
|
||||
payload,
|
||||
{
|
||||
recoverGap: (agentId, cursor, observedSeq) => sync.recoverGap(agentId, cursor, observedSeq),
|
||||
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
|
||||
},
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...sync,
|
||||
attachTransport(ports) {
|
||||
sync.setConnected(false);
|
||||
transport = ports;
|
||||
sync.setDeliveryMode(ports.initialDeliveryMode);
|
||||
return () => {
|
||||
if (transport !== ports) return;
|
||||
sync.setConnected(false);
|
||||
transport = undefined;
|
||||
};
|
||||
applyTimelineResponse(payload) {
|
||||
const accepted = applyAuthoritativeTimelineResponse({
|
||||
serverId: input.serverId,
|
||||
payload,
|
||||
cachedCursor: input.replica.readRange(payload.agentId),
|
||||
recoverGap: (agentId, cursor) => sync.recoverGap(agentId, cursor),
|
||||
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
|
||||
});
|
||||
if (accepted) input.replica.timelineUpdated(payload.agentId);
|
||||
},
|
||||
acceptAgent(agentId) {
|
||||
input.replica.acceptAgent(agentId);
|
||||
sync.beginAgentLifetime(agentId);
|
||||
},
|
||||
removeAgent(agentId, reason) {
|
||||
streamQueue.flushAgent(agentId);
|
||||
sync.endAgentLifetime(agentId);
|
||||
input.replica.remove(agentId, reason);
|
||||
},
|
||||
beginTimelineRequest(agentId, replaceTail = false) {
|
||||
const request = {
|
||||
cursor: input.replica.readCursor(agentId) ?? null,
|
||||
replaceTail,
|
||||
initialization: getInitDeferred(getInitKey(input.serverId, agentId))?.promise,
|
||||
};
|
||||
return (payload) => applyTimelineResponse(payload, request);
|
||||
},
|
||||
applyTimelineResponse,
|
||||
enqueueStreamEvent(agentId, event) {
|
||||
streamQueue.enqueue(agentId, event);
|
||||
},
|
||||
@@ -185,11 +411,6 @@ interface CatchUpState {
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
|
||||
interface VisibleAgentSource {
|
||||
declaration: object;
|
||||
agentIds: string[];
|
||||
}
|
||||
|
||||
const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => {
|
||||
if (previousDelayMs == null) {
|
||||
return RETRY_DELAY_MS;
|
||||
@@ -197,6 +418,40 @@ const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => {
|
||||
return Math.min(previousDelayMs * 2, MAX_RETRY_DELAY_MS);
|
||||
};
|
||||
|
||||
function isSameCatchUpRequest(
|
||||
left: ProjectedTimelineForwardFetchPlan | undefined,
|
||||
right: ProjectedTimelineForwardFetchPlan | undefined,
|
||||
): boolean {
|
||||
if (!left || !right || left.direction !== right.direction) return false;
|
||||
if (left.direction !== "after" || right.direction !== "after") return true;
|
||||
return left.cursor.epoch === right.cursor.epoch && left.cursor.seq === right.cursor.seq;
|
||||
}
|
||||
|
||||
type CatchUpDecision = "keep" | "keep-and-park" | "replace";
|
||||
|
||||
function decideCatchUp(input: {
|
||||
current: CatchUpState | undefined;
|
||||
request: ProjectedTimelineForwardFetchPlan;
|
||||
supersede: boolean;
|
||||
}): CatchUpDecision {
|
||||
if (!input.current) return "replace";
|
||||
if (input.supersede) {
|
||||
if (
|
||||
input.current.status === "running" &&
|
||||
isSameCatchUpRequest(input.current.request, input.request)
|
||||
) {
|
||||
return "keep";
|
||||
}
|
||||
if (input.current.status === "running" && input.current.request?.direction === "tail") {
|
||||
return "keep-and-park";
|
||||
}
|
||||
return "replace";
|
||||
}
|
||||
return input.current.status === "running" || input.current.status === "complete"
|
||||
? "keep"
|
||||
: "replace";
|
||||
}
|
||||
|
||||
function normalizeAgentIds(agentIds: string[]): string[] {
|
||||
return [...new Set(agentIds)].filter(Boolean).sort();
|
||||
}
|
||||
@@ -206,13 +461,12 @@ function sameAgentIds(left: string[], right: string[]): boolean {
|
||||
}
|
||||
|
||||
export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): ViewedTimelineSync {
|
||||
const sources = new Map<string, VisibleAgentSource>();
|
||||
const sources = new Map<string, string[]>();
|
||||
const catchUps = new Map<string, CatchUpState>();
|
||||
const catchUpGenerations = new Map<string, number>();
|
||||
// The newest live sequence each agent has been told about but whose coverage is not yet
|
||||
// established. A catch-up is complete only when authoritative coverage reaches it; a page
|
||||
// built before that sequence existed cannot settle it, however it was requested.
|
||||
const observedHeads = new Map<string, { epoch: string; seq: number }>();
|
||||
// Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked.
|
||||
// Acknowledgement and tail completion are the only drain points.
|
||||
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
||||
const visibilityCatchUpPending = new Set<string>();
|
||||
const visibilityCatchUpErrors = new Map<string, string>();
|
||||
// User-initiated retries only. Background retries stay silent; a retry the user asked for
|
||||
@@ -235,8 +489,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
let cancelMembershipRetry: (() => void) | null = null;
|
||||
let recentlyViewedAgentIds: string[] = [];
|
||||
|
||||
const visibleAgentIds = () =>
|
||||
normalizeAgentIds([...sources.values()].flatMap((source) => source.agentIds));
|
||||
const visibleAgentIds = () => normalizeAgentIds([...sources.values()].flat());
|
||||
|
||||
const selectHotAgentIds = (visible: string[]) => {
|
||||
const visibleSet = new Set(visible);
|
||||
@@ -290,36 +543,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
if (changed) notifyListeners();
|
||||
};
|
||||
|
||||
// Cancelling forgets the observed head: the next catch-up starts from the current cursor and
|
||||
// the daemon returns everything after it.
|
||||
const cancelCatchUp = (agentId: string) => {
|
||||
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
|
||||
catchUps.get(agentId)?.cancelRetry?.();
|
||||
catchUps.delete(agentId);
|
||||
observedHeads.delete(agentId);
|
||||
};
|
||||
|
||||
const owedAfter = (
|
||||
agentId: string,
|
||||
cursorBefore: { epoch: string; endSeq: number } | undefined,
|
||||
observedBefore: { epoch: string; seq: number } | undefined,
|
||||
): ProjectedTimelineForwardFetchPlan | undefined => {
|
||||
const observed = observedHeads.get(agentId);
|
||||
const cursor = ports.readCursor(agentId);
|
||||
if (!observed || !cursor) return undefined;
|
||||
const advanced =
|
||||
cursorBefore === undefined ||
|
||||
cursorBefore.epoch !== cursor.epoch ||
|
||||
cursor.endSeq > cursorBefore.endSeq;
|
||||
if (cursor.epoch === observed.epoch && cursor.endSeq < observed.seq) {
|
||||
if (!advanced && observed === observedBefore)
|
||||
throw new Error(
|
||||
`Timeline coverage for ${agentId} has not reached observed sequence ${observed.seq}`,
|
||||
);
|
||||
return planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq });
|
||||
}
|
||||
observedHeads.delete(agentId);
|
||||
return undefined;
|
||||
pendingCatchUps.delete(agentId);
|
||||
};
|
||||
|
||||
const fetchUntilCurrent = async (
|
||||
@@ -331,41 +559,32 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
if (!ownsCatchUp(agentId, generation)) return;
|
||||
|
||||
try {
|
||||
let cursorBefore = ports.readCursor(agentId);
|
||||
let observedBefore = observedHeads.get(agentId);
|
||||
const page = await ports.fetchPage(agentId, request);
|
||||
if (!ownsCatchUp(agentId, generation)) return;
|
||||
if (page.hasNewer && page.endCursor) {
|
||||
if (
|
||||
request.direction === "after" &&
|
||||
page.endCursor.epoch === request.cursor.epoch &&
|
||||
page.endCursor.seq <= request.cursor.seq
|
||||
) {
|
||||
throw new Error(`Timeline page for ${agentId} did not advance its cursor`);
|
||||
}
|
||||
if (fallbackToLatestTailOnOverflow) {
|
||||
cursorBefore = ports.readCursor(agentId);
|
||||
observedBefore = observedHeads.get(agentId);
|
||||
await ports.fetchLatestTail(agentId);
|
||||
if (!ownsCatchUp(agentId, generation)) return;
|
||||
} else {
|
||||
await fetchUntilCurrent(
|
||||
agentId,
|
||||
generation,
|
||||
planTimelineCatchUpAfter(page.endCursor),
|
||||
false,
|
||||
);
|
||||
catchUps.set(agentId, { generation, status: "complete" });
|
||||
setVisibilityCatchUpReady(agentId);
|
||||
return;
|
||||
}
|
||||
} else if (page.hasNewer) {
|
||||
throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`);
|
||||
}
|
||||
const owed = owedAfter(agentId, cursorBefore, observedBefore);
|
||||
if (owed) {
|
||||
await fetchUntilCurrent(agentId, generation, owed, false);
|
||||
await fetchUntilCurrent(
|
||||
agentId,
|
||||
generation,
|
||||
planTimelineCatchUpAfter(page.endCursor),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (page.hasNewer) {
|
||||
throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`);
|
||||
}
|
||||
catchUps.set(agentId, { generation, status: "complete" });
|
||||
const pendingCatchUp = pendingCatchUps.get(agentId);
|
||||
if (pendingCatchUp) {
|
||||
startCatchUp(agentId, { request: pendingCatchUp, supersede: true });
|
||||
return;
|
||||
}
|
||||
setVisibilityCatchUpReady(agentId);
|
||||
} catch (error) {
|
||||
if (catchUps.get(agentId)?.generation === generation) {
|
||||
@@ -394,43 +613,69 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
.prepare(agentId)
|
||||
.catch((error) => ports.reportError(error))
|
||||
.finally(() => {
|
||||
if (cacheLoads.get(agentId) !== load) return;
|
||||
cacheLoads.delete(agentId);
|
||||
loadedCache.add(agentId);
|
||||
startCatchUp(agentId);
|
||||
const pending = pendingCatchUps.get(agentId);
|
||||
startCatchUp(agentId, { request: pending, supersede: Boolean(pending) });
|
||||
});
|
||||
cacheLoads.set(agentId, load);
|
||||
};
|
||||
|
||||
// One catch-up per agent: it runs from the current cursor until the daemon reports nothing
|
||||
// newer and coverage reaches every observed sequence. A running catch-up is never replaced
|
||||
// by another request; it finishes its own obligation. `supersede` restarts a failed or
|
||||
// completed one immediately, which the manual retry and a new gap both need.
|
||||
const startCatchUp = (agentId: string, options: { supersede?: boolean } = {}) => {
|
||||
const { supersede = false } = options;
|
||||
if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) return;
|
||||
const startCatchUp = (
|
||||
agentId: string,
|
||||
options: {
|
||||
request?: ProjectedTimelineForwardFetchPlan;
|
||||
supersede?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const { request, supersede = false } = options;
|
||||
if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) {
|
||||
if (request) pendingCatchUps.set(agentId, request);
|
||||
return;
|
||||
}
|
||||
if (!loadedCache.has(agentId)) {
|
||||
if (request) pendingCatchUps.set(agentId, request);
|
||||
ensureCacheLoaded(agentId);
|
||||
return;
|
||||
}
|
||||
const nextRequest = request ?? planTimelineResumeFetch(ports.readCursor(agentId));
|
||||
const current = catchUps.get(agentId);
|
||||
if (current?.status === "running") return;
|
||||
if (current?.status === "complete" && !supersede) return;
|
||||
const decision = decideCatchUp({ current, request: nextRequest, supersede });
|
||||
if (decision === "keep-and-park") {
|
||||
pendingCatchUps.set(agentId, nextRequest);
|
||||
return;
|
||||
}
|
||||
if (decision === "keep") {
|
||||
return;
|
||||
}
|
||||
current?.cancelRetry?.();
|
||||
const request = planTimelineResumeFetch(ports.readCursor(agentId));
|
||||
const generation = (catchUpGenerations.get(agentId) ?? 0) + 1;
|
||||
catchUpGenerations.set(agentId, generation);
|
||||
const retryDelayMs =
|
||||
supersede || current?.status !== "error" ? undefined : current.retryDelayMs;
|
||||
catchUps.set(agentId, { generation, status: "running", request, retryDelayMs });
|
||||
// A resume with no observed head replays at most one bounded page before taking the latest
|
||||
// tail; a catch-up that owes observed live rows pages forward until it reaches them.
|
||||
const resumingWithoutObservation = request.direction === "after" && !observedHeads.has(agentId);
|
||||
void fetchUntilCurrent(agentId, generation, request, resumingWithoutObservation);
|
||||
catchUps.set(agentId, {
|
||||
generation,
|
||||
status: "running",
|
||||
request: nextRequest,
|
||||
retryDelayMs,
|
||||
});
|
||||
pendingCatchUps.delete(agentId);
|
||||
void fetchUntilCurrent(
|
||||
agentId,
|
||||
generation,
|
||||
nextRequest,
|
||||
request === undefined && nextRequest.direction === "after",
|
||||
);
|
||||
};
|
||||
|
||||
const startAcknowledgedCatchUps = () => {
|
||||
for (const agentId of acknowledged) startCatchUp(agentId);
|
||||
for (const agentId of acknowledged) {
|
||||
const pendingCatchUp = pendingCatchUps.get(agentId);
|
||||
startCatchUp(agentId, {
|
||||
request: pendingCatchUp,
|
||||
supersede: Boolean(pendingCatchUp),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const reconcileLatestMembership = async (): Promise<void> => {
|
||||
@@ -510,7 +755,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
notifyListeners();
|
||||
if (catchUp?.status === "error") {
|
||||
catchUp.cancelRetry?.();
|
||||
startCatchUp(agentId, { supersede: true });
|
||||
startCatchUp(agentId, { request: catchUp.request, supersede: true });
|
||||
return;
|
||||
}
|
||||
cancelMembershipRetry?.();
|
||||
@@ -596,26 +841,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
getAgentTimelineError(agentId) {
|
||||
return visibilityCatchUpErrors.get(agentId) ?? null;
|
||||
},
|
||||
registerVisibleAgentIds(sourceId, agentIds) {
|
||||
const declaration = {};
|
||||
sources.set(sourceId, {
|
||||
declaration,
|
||||
agentIds: normalizeAgentIds(agentIds),
|
||||
});
|
||||
replaceVisibleAgentIds(sourceId, agentIds) {
|
||||
const normalized = normalizeAgentIds(agentIds);
|
||||
if (normalized.length === 0) sources.delete(sourceId);
|
||||
else sources.set(sourceId, normalized);
|
||||
publishVisibleMembership();
|
||||
return {
|
||||
replace(nextAgentIds) {
|
||||
const current = sources.get(sourceId);
|
||||
if (current?.declaration !== declaration) return;
|
||||
current.agentIds = normalizeAgentIds(nextAgentIds);
|
||||
publishVisibleMembership();
|
||||
},
|
||||
dispose() {
|
||||
if (sources.get(sourceId)?.declaration !== declaration) return;
|
||||
sources.delete(sourceId);
|
||||
publishVisibleMembership();
|
||||
},
|
||||
};
|
||||
},
|
||||
setActive(nextActive) {
|
||||
if (active === nextActive) return;
|
||||
@@ -667,30 +897,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
if (deliveryMode === "selective" && connected) void reconcileMembership();
|
||||
else if (connected) startAcknowledgedCatchUps();
|
||||
},
|
||||
recoverGap(agentId, cursor, observedSeq) {
|
||||
recoverGap(agentId, cursor) {
|
||||
if (!isDesired(agentId)) return;
|
||||
if (observedSeq !== undefined) {
|
||||
const observed = observedHeads.get(agentId);
|
||||
if (!observed || observed.epoch !== cursor.epoch || observed.seq < observedSeq) {
|
||||
observedHeads.set(agentId, { epoch: cursor.epoch, seq: observedSeq });
|
||||
}
|
||||
}
|
||||
startCatchUp(agentId, { supersede: true });
|
||||
},
|
||||
beginAgentLifetime(agentId) {
|
||||
if (!isDesired(agentId)) return;
|
||||
visibilityCatchUpPending.add(agentId);
|
||||
ensureCacheLoaded(agentId);
|
||||
notifyListeners();
|
||||
},
|
||||
endAgentLifetime(agentId) {
|
||||
cancelCatchUp(agentId);
|
||||
loadedCache.delete(agentId);
|
||||
cacheLoads.delete(agentId);
|
||||
if (isDesired(agentId)) {
|
||||
visibilityCatchUpPending.add(agentId);
|
||||
notifyListeners();
|
||||
}
|
||||
startCatchUp(agentId, {
|
||||
request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }),
|
||||
supersede: true,
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
@@ -707,7 +919,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
recentlyViewedAgentIds = [];
|
||||
loadedCache.clear();
|
||||
cacheLoads.clear();
|
||||
observedHeads.clear();
|
||||
visibilityCatchUpPending.clear();
|
||||
visibilityCatchUpErrors.clear();
|
||||
manualRetries.clear();
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
handoffCreatedAgentUserMessageToStream,
|
||||
hydrateStreamState,
|
||||
mergeToolCallDetail,
|
||||
mergeCanonicalText,
|
||||
reduceStreamUpdate,
|
||||
streamTimelineItemIdentity,
|
||||
type AgentToolCallItem,
|
||||
@@ -24,122 +23,6 @@ import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
|
||||
|
||||
type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled";
|
||||
|
||||
describe("canonical text source boundaries", () => {
|
||||
it("keeps the canonical start independently of its completion chunk", () => {
|
||||
const rows = mergeCanonicalText(
|
||||
[
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "m",
|
||||
messageId: "m",
|
||||
text: "ab",
|
||||
timestamp: new Date(0),
|
||||
timelineCursor: { epoch: "e", seq: 2 },
|
||||
source: {
|
||||
startSeq: 1,
|
||||
chunks: [
|
||||
{ seq: 1, offset: 0 },
|
||||
{ seq: 2, offset: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
{ text: "ab", epoch: "e", startSeq: 1, endSeq: 2, timestamp: new Date(1) },
|
||||
);
|
||||
expect(rows[0]).toMatchObject({
|
||||
text: "ab",
|
||||
timelineCursor: { epoch: "e", seq: 2 },
|
||||
source: { startSeq: 1, chunks: [{ seq: 2, offset: 0 }] },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps exact offsets when canonical text replaces the middle of a row", () => {
|
||||
const [row] = mergeCanonicalText(
|
||||
[
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "m",
|
||||
text: "abOLDyz",
|
||||
timestamp: new Date(0),
|
||||
timelineCursor: { epoch: "e", seq: 8 },
|
||||
source: {
|
||||
startSeq: 1,
|
||||
chunks: [
|
||||
{ seq: 2, offset: 0 },
|
||||
{ seq: 4, offset: 2 },
|
||||
{ seq: 8, offset: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
{ text: "C", epoch: "e", startSeq: 3, endSeq: 6, timestamp: new Date(1) },
|
||||
);
|
||||
expect(row).toMatchObject({
|
||||
text: "abCyz",
|
||||
timelineCursor: { epoch: "e", seq: 8 },
|
||||
source: {
|
||||
startSeq: 1,
|
||||
chunks: [
|
||||
{ seq: 2, offset: 0 },
|
||||
{ seq: 6, offset: 2 },
|
||||
{ seq: 8, offset: 3 },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves source starts and exact text before and after covered chunks", () => {
|
||||
const rows = mergeCanonicalText(
|
||||
[
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "before",
|
||||
text: "before",
|
||||
timestamp: new Date(0),
|
||||
timelineCursor: { epoch: "e", seq: 2 },
|
||||
source: { startSeq: 1, chunks: [{ seq: 2, offset: 0 }] },
|
||||
},
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "covered",
|
||||
text: "old",
|
||||
timestamp: new Date(0),
|
||||
timelineCursor: { epoch: "e", seq: 4 },
|
||||
source: { startSeq: 3, chunks: [{ seq: 4, offset: 0 }] },
|
||||
},
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "after",
|
||||
text: "after",
|
||||
timestamp: new Date(0),
|
||||
timelineCursor: { epoch: "e", seq: 8 },
|
||||
source: { startSeq: 7, chunks: [{ seq: 8, offset: 0 }] },
|
||||
},
|
||||
],
|
||||
{ text: "canonical", epoch: "e", startSeq: 3, endSeq: 6, timestamp: new Date(1) },
|
||||
);
|
||||
expect(
|
||||
rows.map((row) => ({ text: row.text, source: row.source, cursor: row.timelineCursor })),
|
||||
).toEqual([
|
||||
{
|
||||
text: "before",
|
||||
source: { startSeq: 1, chunks: [{ seq: 2, offset: 0 }] },
|
||||
cursor: { epoch: "e", seq: 2 },
|
||||
},
|
||||
{
|
||||
text: "canonical",
|
||||
source: { startSeq: 3, chunks: [{ seq: 6, offset: 0 }] },
|
||||
cursor: { epoch: "e", seq: 6 },
|
||||
},
|
||||
{
|
||||
text: "after",
|
||||
source: { startSeq: 7, chunks: [{ seq: 8, offset: 0 }] },
|
||||
cursor: { epoch: "e", seq: 8 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin timeline rows", () => {
|
||||
it("uses the protocol identity format for stream tool and plugin rows", () => {
|
||||
const tool = {
|
||||
|
||||
+138
-449
@@ -97,7 +97,6 @@ export interface UserMessageItem {
|
||||
messageId?: string;
|
||||
turnId?: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
source?: RowSource;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
images?: UserMessageImageAttachment[];
|
||||
@@ -110,7 +109,6 @@ export interface UserMessageInput {
|
||||
messageId?: string;
|
||||
turnId?: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
source?: RowSource;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
images?: UserMessageImageAttachment[];
|
||||
@@ -129,7 +127,6 @@ export function createUserMessage(input: UserMessageInput): UserMessageItem {
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
...(input.turnId ? { turnId: input.turnId } : {}),
|
||||
...(input.timelineCursor ? { timelineCursor: input.timelineCursor } : {}),
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
text: input.text,
|
||||
timestamp: input.timestamp,
|
||||
...(input.images && input.images.length > 0 ? { images: input.images } : {}),
|
||||
@@ -264,14 +261,12 @@ function produceUserMessage(
|
||||
clientMessageId: incoming.clientMessageId ?? existing.clientMessageId,
|
||||
messageId: incoming.messageId ?? existing.messageId,
|
||||
timelineCursor: incoming.timelineCursor ?? existing.timelineCursor,
|
||||
source: incoming.source ?? existing.source,
|
||||
});
|
||||
if (
|
||||
existing.id === merged.id &&
|
||||
existing.clientMessageId === merged.clientMessageId &&
|
||||
existing.messageId === merged.messageId &&
|
||||
existing.timelineCursor === merged.timelineCursor &&
|
||||
existing.source === merged.source &&
|
||||
existing.text === merged.text &&
|
||||
existing.timestamp === merged.timestamp &&
|
||||
existing.images === merged.images &&
|
||||
@@ -417,19 +412,13 @@ function placeCanonicalUserMessageAtTail(
|
||||
};
|
||||
}
|
||||
|
||||
// What canonical rows certify about source positions. A certified range replaces every positioned
|
||||
// row inside it and drops rows from other epochs. Rows-only canonical data, such as a display-only
|
||||
// cache, replaces only rows sharing a canonical row's position or an older-or-equal copy of its
|
||||
// message identity; it never drops rows it did not cover.
|
||||
export type CanonicalCoverage = { epoch: string; endSeq: number | null } | "rows-only";
|
||||
|
||||
export interface CanonicalStreamReplacementInput {
|
||||
canonical: StreamItem[];
|
||||
previousTail: StreamItem[];
|
||||
previousHead: StreamItem[];
|
||||
sendingClientMessageIds: readonly string[];
|
||||
preserveContinuity: boolean;
|
||||
canonicalCoverage: CanonicalCoverage;
|
||||
canonicalCoverage: { epoch: string; endSeq: number | null };
|
||||
}
|
||||
|
||||
export interface CanonicalStreamReplacementResult {
|
||||
@@ -438,33 +427,13 @@ export interface CanonicalStreamReplacementResult {
|
||||
acknowledgedClientMessageIds: string[];
|
||||
}
|
||||
|
||||
function isSamePosition(left: TimelinePosition | undefined, right: TimelinePosition): boolean {
|
||||
return left !== undefined && left.epoch === right.epoch && left.seq === right.seq;
|
||||
}
|
||||
|
||||
function isCoveredByCanonical(
|
||||
item: StreamItem,
|
||||
canonical: readonly StreamItem[],
|
||||
coverage: CanonicalCoverage,
|
||||
function isAfterCanonicalCoverage(
|
||||
position: TimelinePosition,
|
||||
coverage: CanonicalStreamReplacementInput["canonicalCoverage"],
|
||||
): boolean {
|
||||
const position = item.timelineCursor;
|
||||
if (!position) return false;
|
||||
if (coverage !== "rows-only") {
|
||||
return (
|
||||
position.epoch !== coverage.epoch ||
|
||||
(coverage.endSeq !== null && position.seq <= coverage.endSeq)
|
||||
);
|
||||
}
|
||||
return canonical.some(
|
||||
(row) =>
|
||||
isSamePosition(row.timelineCursor, position) ||
|
||||
(row.kind === "assistant_message" &&
|
||||
item.kind === "assistant_message" &&
|
||||
row.messageId !== undefined &&
|
||||
row.messageId === item.messageId &&
|
||||
row.timelineCursor !== undefined &&
|
||||
row.timelineCursor.epoch === position.epoch &&
|
||||
row.timelineCursor.seq >= position.seq),
|
||||
return (
|
||||
position.epoch === coverage.epoch &&
|
||||
(coverage.endSeq === null || position.seq > coverage.endSeq)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -574,53 +543,29 @@ function reconcileReplacementHeadAgainstTail(
|
||||
};
|
||||
}
|
||||
|
||||
// Positioned rows beyond canonical coverage are daemon history the page did not carry, so they
|
||||
// survive every replacement. Continuity decides only what happens to rows without a position.
|
||||
function retainsAcrossReplacement(
|
||||
item: StreamItem,
|
||||
input: {
|
||||
canonical: readonly StreamItem[];
|
||||
tail: readonly StreamItem[];
|
||||
preserveContinuity: boolean;
|
||||
sendingClientMessageIds: ReadonlySet<string>;
|
||||
canonicalCoverage: CanonicalCoverage;
|
||||
},
|
||||
): boolean {
|
||||
if (item.timelineCursor) {
|
||||
if (!isCoveredByCanonical(item, input.canonical, input.canonicalCoverage)) return true;
|
||||
const canonicalTailAssistant = input.tail.at(-1);
|
||||
return (
|
||||
input.preserveContinuity &&
|
||||
item.kind === "assistant_message" &&
|
||||
canonicalTailAssistant?.kind === "assistant_message" &&
|
||||
item.text.startsWith(canonicalTailAssistant.text)
|
||||
);
|
||||
}
|
||||
return (
|
||||
input.preserveContinuity ||
|
||||
(item.kind === "user_message" &&
|
||||
item.clientMessageId !== undefined &&
|
||||
input.sendingClientMessageIds.has(item.clientMessageId))
|
||||
);
|
||||
}
|
||||
|
||||
function preserveReplacementHead(
|
||||
canonical: readonly StreamItem[],
|
||||
tail: StreamItem[],
|
||||
currentHead: StreamItem[],
|
||||
preserveContinuity: boolean,
|
||||
sendingClientMessageIds: ReadonlySet<string>,
|
||||
canonicalCoverage: CanonicalCoverage,
|
||||
canonicalCoverage: CanonicalStreamReplacementInput["canonicalCoverage"],
|
||||
): CanonicalStreamReplacementResult {
|
||||
const retainedHead = currentHead.filter((item) =>
|
||||
retainsAcrossReplacement(item, {
|
||||
canonical,
|
||||
tail,
|
||||
preserveContinuity,
|
||||
sendingClientMessageIds,
|
||||
canonicalCoverage,
|
||||
}),
|
||||
);
|
||||
const canonicalTailAssistant = tail.at(-1);
|
||||
const retainedHead = preserveContinuity
|
||||
? currentHead.filter(
|
||||
(item) =>
|
||||
!item.timelineCursor ||
|
||||
isAfterCanonicalCoverage(item.timelineCursor, canonicalCoverage) ||
|
||||
(item.kind === "assistant_message" &&
|
||||
canonicalTailAssistant?.kind === "assistant_message" &&
|
||||
item.text.startsWith(canonicalTailAssistant.text)),
|
||||
)
|
||||
: currentHead.filter(
|
||||
(item) =>
|
||||
item.kind === "user_message" &&
|
||||
item.clientMessageId !== undefined &&
|
||||
sendingClientMessageIds.has(item.clientMessageId),
|
||||
);
|
||||
const { tail: reconciledTail, head: unreconciledHead } = reconcileReplacementHeadAgainstTail(
|
||||
tail,
|
||||
retainedHead,
|
||||
@@ -639,29 +584,7 @@ function preserveReplacementHead(
|
||||
) {
|
||||
return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
|
||||
}
|
||||
const continued = continueCanonicalAssistant(liveAssistant, tailAssistant);
|
||||
if (!continued) {
|
||||
return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
|
||||
}
|
||||
return {
|
||||
tail: reconciledTail.slice(0, -1),
|
||||
head: [
|
||||
...unreconciledHead.slice(0, liveAssistantIndex),
|
||||
continued,
|
||||
...unreconciledHead.slice(liveAssistantIndex + 1),
|
||||
],
|
||||
acknowledgedClientMessageIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
// The live head row that continues the canonical tail's last assistant, merged so the canonical
|
||||
// text owns the positions it certifies and the live row keeps what came after. Rows without
|
||||
// provider ids cannot tell a continuation from the next message, so they keep the conservative
|
||||
// legacy rule and merge only when the live text visibly extends the canonical one.
|
||||
function continueCanonicalAssistant(
|
||||
liveAssistant: AssistantMessageItem,
|
||||
tailAssistant: AssistantMessageItem,
|
||||
): AssistantMessageItem | null {
|
||||
const hasNewerCursor =
|
||||
liveAssistant.timelineCursor !== undefined &&
|
||||
tailAssistant.timelineCursor !== undefined &&
|
||||
@@ -669,29 +592,41 @@ function continueCanonicalAssistant(
|
||||
liveAssistant.timelineCursor.seq > tailAssistant.timelineCursor.seq;
|
||||
const hasMatchingProviderMessageId =
|
||||
liveAssistant.messageId !== undefined && liveAssistant.messageId === tailAssistant.messageId;
|
||||
const extendsText = liveAssistant.text.startsWith(tailAssistant.text);
|
||||
const hasIdlessTextContinuation =
|
||||
liveAssistant.messageId === undefined && tailAssistant.messageId === undefined && extendsText;
|
||||
if (hasNewerCursor && (hasMatchingProviderMessageId || hasIdlessTextContinuation)) {
|
||||
if (liveAssistant.timelineCursor && tailAssistant.timelineCursor) {
|
||||
return (
|
||||
mergeCanonicalText([liveAssistant], {
|
||||
text: tailAssistant.text,
|
||||
epoch: tailAssistant.timelineCursor.epoch,
|
||||
startSeq: rowStartSeq(tailAssistant) ?? tailAssistant.timelineCursor.seq,
|
||||
endSeq: tailAssistant.timelineCursor.seq,
|
||||
timestamp: tailAssistant.timestamp,
|
||||
})[0] ?? liveAssistant
|
||||
);
|
||||
}
|
||||
liveAssistant.messageId === undefined &&
|
||||
tailAssistant.messageId === undefined &&
|
||||
liveAssistant.text.startsWith(tailAssistant.text);
|
||||
const isNewerContinuation =
|
||||
hasNewerCursor && (hasMatchingProviderMessageId || hasIdlessTextContinuation);
|
||||
if (isNewerContinuation) {
|
||||
const text = liveAssistant.text.startsWith(tailAssistant.text)
|
||||
? liveAssistant.text
|
||||
: `${tailAssistant.text}${liveAssistant.text}`;
|
||||
const head = [
|
||||
...unreconciledHead.slice(0, liveAssistantIndex),
|
||||
{ ...liveAssistant, text },
|
||||
...unreconciledHead.slice(liveAssistantIndex + 1),
|
||||
];
|
||||
return {
|
||||
...liveAssistant,
|
||||
text: extendsText ? liveAssistant.text : `${tailAssistant.text}${liveAssistant.text}`,
|
||||
tail: reconciledTail.slice(0, -1),
|
||||
head,
|
||||
acknowledgedClientMessageIds: [],
|
||||
};
|
||||
}
|
||||
// A live prefix of the canonical text is the same message still streaming from the client's
|
||||
// view; the canonical text is complete as of the page.
|
||||
return extendsText ? { ...liveAssistant, text: tailAssistant.text } : null;
|
||||
if (!liveAssistant.text.startsWith(tailAssistant.text)) {
|
||||
return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
|
||||
}
|
||||
|
||||
const head = [
|
||||
...unreconciledHead.slice(0, liveAssistantIndex),
|
||||
{ ...liveAssistant, text: tailAssistant.text },
|
||||
...unreconciledHead.slice(liveAssistantIndex + 1),
|
||||
];
|
||||
return {
|
||||
tail: reconciledTail.slice(0, -1),
|
||||
head,
|
||||
acknowledgedClientMessageIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function replaceWithCanonicalStream(
|
||||
@@ -738,29 +673,24 @@ export function replaceWithCanonicalStream(
|
||||
nextTail.push(item);
|
||||
}
|
||||
|
||||
const retainedTailRows: StreamItem[] = [];
|
||||
for (const item of input.previousTail) {
|
||||
if (item.kind === "user_message" && item.clientMessageId !== undefined) {
|
||||
if (!unmatchedTailMessages.includes(item)) continue;
|
||||
const preserveLocal = input.preserveContinuity
|
||||
? isUnreconciledLocalUserMessage(item)
|
||||
: sendingClientMessageIds.has(item.clientMessageId);
|
||||
if (preserveLocal) {
|
||||
nextTail.push(item);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (
|
||||
item.timelineCursor &&
|
||||
!isCoveredByCanonical(item, input.canonical, input.canonicalCoverage)
|
||||
const retainedTailMessages: UserMessageItem[] = [];
|
||||
for (const local of unmatchedTailMessages) {
|
||||
const preserveLocal = input.preserveContinuity
|
||||
? isUnreconciledLocalUserMessage(local)
|
||||
: local.clientMessageId !== undefined && sendingClientMessageIds.has(local.clientMessageId);
|
||||
if (preserveLocal) {
|
||||
nextTail.push(local);
|
||||
} else if (
|
||||
input.preserveContinuity &&
|
||||
local.timelineCursor &&
|
||||
isAfterCanonicalCoverage(local.timelineCursor, input.canonicalCoverage)
|
||||
) {
|
||||
retainedTailRows.push(item);
|
||||
retainedTailMessages.push(local);
|
||||
}
|
||||
}
|
||||
nextHead = [...retainedTailRows, ...nextHead];
|
||||
nextHead = [...retainedTailMessages, ...nextHead];
|
||||
|
||||
const replacement = preserveReplacementHead(
|
||||
input.canonical,
|
||||
nextTail,
|
||||
nextHead,
|
||||
input.preserveContinuity,
|
||||
@@ -783,7 +713,6 @@ export interface AssistantMessageItem {
|
||||
timestamp: Date;
|
||||
blockGroupId?: string;
|
||||
blockIndex?: number;
|
||||
source?: RowSource;
|
||||
}
|
||||
|
||||
export interface TimelinePosition {
|
||||
@@ -791,25 +720,6 @@ export interface TimelinePosition {
|
||||
seq: number;
|
||||
}
|
||||
|
||||
// Where a row's content came from. `startSeq` is the first source position the row covers; rows
|
||||
// display in start order because a tool call completes long after it appeared. `chunks` records,
|
||||
// for streamed text, the sequence that produced each piece and the offset it begins at, so a
|
||||
// canonical page owns exactly the chunks inside its source coverage and text merges cut on chunk
|
||||
// boundaries instead of comparing strings. Rows saved before provenance existed order by `seq`.
|
||||
export interface RowSource {
|
||||
startSeq: number;
|
||||
chunks?: SourceChunk[];
|
||||
}
|
||||
|
||||
export interface SourceChunk {
|
||||
seq: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// Reducers receive the position of the source rows an event covers; canonical units start before
|
||||
// they end. Rows keep only the anchor position; the start goes into `source`.
|
||||
export type SourcedPosition = TimelinePosition & { startSeq?: number };
|
||||
|
||||
export type ThoughtStatus = "loading" | "ready";
|
||||
|
||||
export interface ThoughtItem {
|
||||
@@ -820,159 +730,6 @@ export interface ThoughtItem {
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
status: ThoughtStatus;
|
||||
source?: RowSource;
|
||||
}
|
||||
|
||||
type TextRow = AssistantMessageItem | ThoughtItem;
|
||||
|
||||
function anchorOf(cursor: SourcedPosition): TimelinePosition;
|
||||
function anchorOf(cursor: SourcedPosition | undefined): TimelinePosition | undefined;
|
||||
function anchorOf(cursor: SourcedPosition | undefined): TimelinePosition | undefined {
|
||||
if (!cursor) return undefined;
|
||||
return cursor.startSeq === undefined ? cursor : { epoch: cursor.epoch, seq: cursor.seq };
|
||||
}
|
||||
|
||||
// The source of a row after an event at `cursor` touches it. A row keeps its start while the
|
||||
// event stays in its epoch; a new epoch starts it over.
|
||||
function sourceFor(cursor: SourcedPosition, existing?: StreamItem): RowSource {
|
||||
if (existing?.timelineCursor?.epoch === cursor.epoch) {
|
||||
const startSeq = existing.source?.startSeq ?? existing.timelineCursor.seq;
|
||||
return existing.source?.startSeq === startSeq && existing.source.chunks === undefined
|
||||
? existing.source
|
||||
: { startSeq };
|
||||
}
|
||||
return { startSeq: cursor.startSeq ?? cursor.seq };
|
||||
}
|
||||
|
||||
function positionedRow(
|
||||
cursor: SourcedPosition | undefined,
|
||||
): Pick<UserMessageItem, "timelineCursor" | "source"> {
|
||||
return cursor ? { timelineCursor: anchorOf(cursor), source: sourceFor(cursor) } : {};
|
||||
}
|
||||
|
||||
export function rowStartSeq(item: StreamItem): number | undefined {
|
||||
return item.source?.startSeq ?? item.timelineCursor?.seq;
|
||||
}
|
||||
|
||||
function rowChunks(row: TextRow): SourceChunk[] {
|
||||
if (row.source?.chunks) return row.source.chunks;
|
||||
return row.timelineCursor ? [{ seq: row.timelineCursor.seq, offset: 0 }] : [];
|
||||
}
|
||||
|
||||
function chunkTexts(row: TextRow): Array<{ seq: number; text: string }> {
|
||||
const chunks = rowChunks(row);
|
||||
return chunks.map((chunk, index) => ({
|
||||
seq: chunk.seq,
|
||||
text: row.text.slice(chunk.offset, chunks[index + 1]?.offset ?? row.text.length),
|
||||
}));
|
||||
}
|
||||
|
||||
function chunksOf(parts: ReadonlyArray<{ seq: number; text: string }>): SourceChunk[] {
|
||||
let offset = 0;
|
||||
return parts.map((part) => {
|
||||
const chunk = { seq: part.seq, offset };
|
||||
offset += part.text.length;
|
||||
return chunk;
|
||||
});
|
||||
}
|
||||
|
||||
function sliceChunks(chunks: readonly SourceChunk[], start: number, end: number): SourceChunk[] {
|
||||
const inside = chunks
|
||||
.filter((chunk) => chunk.offset >= start && chunk.offset < end)
|
||||
.map((chunk) => ({ seq: chunk.seq, offset: chunk.offset - start }));
|
||||
const spanning = chunks.findLast((chunk) => chunk.offset < start);
|
||||
if (spanning && inside[0]?.offset !== 0) inside.unshift({ seq: spanning.seq, offset: 0 });
|
||||
return inside;
|
||||
}
|
||||
|
||||
export interface CanonicalTextUnit {
|
||||
text: string;
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// A canonical text unit owns the source positions from `startSeq` through `endSeq`. Among the rows
|
||||
// that belong to the same message it replaces exactly the chunks inside that coverage, keeps chunks
|
||||
// before it and after it, and lands where the covered chunks were, so a page that lags the live
|
||||
// stream restores the prefix it certifies without touching text the stream delivered later. Rows
|
||||
// keep their identity and split; rows left with no text are dropped.
|
||||
function coverageOf(seq: number, unit: CanonicalTextUnit): "before" | "covered" | "after" {
|
||||
if (seq < unit.startSeq) return "before";
|
||||
return seq > unit.endSeq ? "after" : "covered";
|
||||
}
|
||||
|
||||
export function mergeCanonicalText<Row extends TextRow>(
|
||||
owned: readonly Row[],
|
||||
unit: CanonicalTextUnit,
|
||||
): Row[] {
|
||||
const parts = owned.map((row) => {
|
||||
const positioned = row.timelineCursor?.epoch === unit.epoch;
|
||||
return chunkTexts(row).map((part, index) => ({
|
||||
seq: part.seq,
|
||||
startSeq: index === 0 ? (rowStartSeq(row) ?? part.seq) : part.seq,
|
||||
text: part.text,
|
||||
where: positioned ? coverageOf(part.seq, unit) : ("covered" as const),
|
||||
}));
|
||||
});
|
||||
let anchor = parts.findIndex((rowParts) => rowParts.some((part) => part.where !== "before"));
|
||||
if (anchor < 0) anchor = owned.length - 1;
|
||||
const result: Row[] = [];
|
||||
for (const [index, row] of owned.entries()) {
|
||||
const rowParts = parts[index] ?? [];
|
||||
if (index < anchor) {
|
||||
result.push(row);
|
||||
continue;
|
||||
}
|
||||
const before = index === anchor ? rowParts.filter((part) => part.where === "before") : [];
|
||||
const after = rowParts.filter((part) => part.where === "after");
|
||||
const kept =
|
||||
index === anchor
|
||||
? [
|
||||
...before,
|
||||
{
|
||||
seq: unit.endSeq,
|
||||
startSeq: unit.startSeq,
|
||||
text: unit.text,
|
||||
where: "covered" as const,
|
||||
},
|
||||
...after,
|
||||
]
|
||||
: after;
|
||||
if (kept.length === 0) continue;
|
||||
const text = kept.map((part) => part.text).join("");
|
||||
const last = kept.at(-1);
|
||||
const first = kept[0];
|
||||
result.push({
|
||||
...row,
|
||||
text,
|
||||
timestamp: index === anchor && after.length === 0 ? unit.timestamp : row.timestamp,
|
||||
timelineCursor: { epoch: unit.epoch, seq: last?.seq ?? unit.endSeq },
|
||||
source: {
|
||||
startSeq: first?.startSeq ?? unit.startSeq,
|
||||
chunks: chunksOf(kept),
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Appends one text row's content to another, keeping every chunk position.
|
||||
export function joinTextRows<Row extends TextRow>(first: Row, second: Row): Row {
|
||||
const parts = [...chunkTexts(first), ...chunkTexts(second)];
|
||||
return {
|
||||
...second,
|
||||
text: `${first.text}${second.text}`,
|
||||
...(second.timelineCursor
|
||||
? {
|
||||
source: {
|
||||
startSeq: rowStartSeq(first) ?? second.timelineCursor.seq,
|
||||
chunks: chunksOf(parts),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export type OrchestratorToolCallStatus = "executing" | "completed" | "failed";
|
||||
@@ -1005,7 +762,6 @@ export interface ToolCallItem {
|
||||
kind: "tool_call";
|
||||
id: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
source?: RowSource;
|
||||
turnId?: string;
|
||||
timestamp: Date;
|
||||
payload: ToolCallPayload;
|
||||
@@ -1026,7 +782,6 @@ export interface NotificationItem {
|
||||
sourceType: "error" | "notification";
|
||||
id: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
source?: RowSource;
|
||||
turnId?: string;
|
||||
timestamp: Date;
|
||||
level: NotificationLevel;
|
||||
@@ -1037,7 +792,6 @@ export interface CompactionItem {
|
||||
kind: "compaction";
|
||||
id: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
source?: RowSource;
|
||||
turnId?: string;
|
||||
timestamp: Date;
|
||||
status: "loading" | "completed";
|
||||
@@ -1049,7 +803,6 @@ export interface PluginTimelineStreamItem {
|
||||
kind: "plugin";
|
||||
id: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
source?: RowSource;
|
||||
turnId?: string;
|
||||
timestamp: Date;
|
||||
pluginId: string;
|
||||
@@ -1075,7 +828,6 @@ export interface TodoListItem {
|
||||
kind: "todo_list";
|
||||
id: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
source?: RowSource;
|
||||
turnId?: string;
|
||||
timestamp: Date;
|
||||
provider: AgentProvider;
|
||||
@@ -1088,7 +840,7 @@ export type StreamUpdateSource = "live" | "canonical";
|
||||
interface StreamUpdateOptions {
|
||||
source?: StreamUpdateSource;
|
||||
reservedItemIds?: ReadonlySet<string>;
|
||||
timelineCursor?: SourcedPosition;
|
||||
timelineCursor?: TimelinePosition;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -1149,7 +901,7 @@ function appendUserMessage(
|
||||
id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp),
|
||||
clientMessageId,
|
||||
messageId,
|
||||
...positionedRow(timelineCursor),
|
||||
timelineCursor,
|
||||
turnId,
|
||||
text: chunk,
|
||||
timestamp,
|
||||
@@ -1164,7 +916,7 @@ function appendAssistantMessage(
|
||||
source: StreamUpdateSource,
|
||||
messageId?: string,
|
||||
reservedItemIds?: ReadonlySet<string>,
|
||||
timelineCursor?: SourcedPosition,
|
||||
timelineCursor?: TimelinePosition,
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!chunk) {
|
||||
@@ -1177,7 +929,13 @@ function appendAssistantMessage(
|
||||
last.kind === "assistant_message" &&
|
||||
(messageId === undefined || last.messageId === messageId);
|
||||
if (shouldAppendToLast) {
|
||||
return [...state.slice(0, -1), extendTextRow(last, chunk, timestamp, timelineCursor)];
|
||||
const updated: AssistantMessageItem = {
|
||||
...last,
|
||||
text: `${last.text}${chunk}`,
|
||||
timestamp,
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
};
|
||||
return [...state.slice(0, -1), updated];
|
||||
}
|
||||
|
||||
// A submitted user row can follow the streaming assistant during interrupt.
|
||||
@@ -1189,11 +947,13 @@ function appendAssistantMessage(
|
||||
secondLast?.kind === "assistant_message" &&
|
||||
(messageId === undefined || secondLast.messageId === messageId)
|
||||
) {
|
||||
return [
|
||||
...state.slice(0, -2),
|
||||
extendTextRow(secondLast, chunk, timestamp, timelineCursor),
|
||||
last,
|
||||
];
|
||||
const updated: AssistantMessageItem = {
|
||||
...secondLast,
|
||||
text: `${secondLast.text}${chunk}`,
|
||||
timestamp,
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
};
|
||||
return [...state.slice(0, -2), updated, last];
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
@@ -1206,53 +966,18 @@ function appendAssistantMessage(
|
||||
kind: "assistant_message",
|
||||
id: entryId,
|
||||
...(messageId ? { messageId } : {}),
|
||||
...startedTextRow(timelineCursor),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
text: chunk,
|
||||
timestamp,
|
||||
};
|
||||
return [...state, item];
|
||||
}
|
||||
|
||||
function startedTextRow(
|
||||
timelineCursor: SourcedPosition | undefined,
|
||||
): Pick<TextRow, "timelineCursor" | "source"> {
|
||||
if (!timelineCursor) return {};
|
||||
return {
|
||||
timelineCursor: anchorOf(timelineCursor),
|
||||
source: {
|
||||
startSeq: timelineCursor.startSeq ?? timelineCursor.seq,
|
||||
chunks: [{ seq: timelineCursor.seq, offset: 0 }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extendTextRow<Row extends TextRow>(
|
||||
row: Row,
|
||||
chunk: string,
|
||||
timestamp: Date,
|
||||
timelineCursor: SourcedPosition | undefined,
|
||||
): Row {
|
||||
return {
|
||||
...row,
|
||||
text: `${row.text}${chunk}`,
|
||||
timestamp,
|
||||
...(timelineCursor
|
||||
? {
|
||||
timelineCursor: anchorOf(timelineCursor),
|
||||
source: {
|
||||
startSeq: sourceFor(timelineCursor, row).startSeq,
|
||||
chunks: [...rowChunks(row), { seq: timelineCursor.seq, offset: row.text.length }],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function appendThought(
|
||||
state: StreamItem[],
|
||||
text: string,
|
||||
timestamp: Date,
|
||||
timelineCursor?: SourcedPosition,
|
||||
timelineCursor?: TimelinePosition,
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!chunk) {
|
||||
@@ -1261,10 +986,14 @@ function appendThought(
|
||||
|
||||
const last = state[state.length - 1];
|
||||
if (last && last.kind === "thought") {
|
||||
return [
|
||||
...state.slice(0, -1),
|
||||
{ ...extendTextRow(last, chunk, timestamp, timelineCursor), status: "loading" },
|
||||
];
|
||||
const updated: ThoughtItem = {
|
||||
...last,
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
text: `${last.text}${chunk}`,
|
||||
timestamp,
|
||||
status: "loading",
|
||||
};
|
||||
return [...state.slice(0, -1), updated];
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
@@ -1275,7 +1004,7 @@ function appendThought(
|
||||
const item: ThoughtItem = {
|
||||
kind: "thought",
|
||||
id: createUniqueTimelineId(state, "thought", idSeed, timestamp),
|
||||
...startedTextRow(timelineCursor),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
text: chunk,
|
||||
timestamp,
|
||||
status: "loading",
|
||||
@@ -1427,7 +1156,7 @@ export function mergeAgentToolCallItem(
|
||||
existing: AgentToolCallItem,
|
||||
data: AgentToolCallData,
|
||||
timestamp: Date,
|
||||
timelineCursor?: SourcedPosition,
|
||||
timelineCursor?: TimelinePosition,
|
||||
): AgentToolCallItem {
|
||||
const mergedStatus = mergeAgentToolCallStatus(existing.payload.data.status, data.status);
|
||||
const mergedError =
|
||||
@@ -1439,9 +1168,7 @@ export function mergeAgentToolCallItem(
|
||||
|
||||
return {
|
||||
...existing,
|
||||
...(timelineCursor
|
||||
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, existing) }
|
||||
: {}),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
timestamp,
|
||||
payload: {
|
||||
source: "agent",
|
||||
@@ -1462,7 +1189,7 @@ interface AppendAgentToolCallInput {
|
||||
data: AgentToolCallData;
|
||||
timestamp: Date;
|
||||
turnId?: string;
|
||||
timelineCursor?: SourcedPosition;
|
||||
timelineCursor?: TimelinePosition;
|
||||
}
|
||||
|
||||
function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] {
|
||||
@@ -1498,9 +1225,7 @@ function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] {
|
||||
const item: ToolCallItem = {
|
||||
kind: "tool_call",
|
||||
id: `agent_tool_${identity}`,
|
||||
...(timelineCursor
|
||||
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) }
|
||||
: {}),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
...(turnId ? { turnId } : {}),
|
||||
timestamp,
|
||||
payload: {
|
||||
@@ -1529,7 +1254,7 @@ function appendPluginTimelineItem(
|
||||
state: StreamItem[],
|
||||
item: Extract<AgentTimelineItem, { type: "plugin" }>,
|
||||
timestamp: Date,
|
||||
timelineCursor?: SourcedPosition,
|
||||
timelineCursor?: TimelinePosition,
|
||||
): StreamItem[] {
|
||||
const identity = timelineItemIdentity(item);
|
||||
if (identity === null) return state;
|
||||
@@ -1542,22 +1267,14 @@ function appendPluginTimelineItem(
|
||||
version: item.version,
|
||||
data: item.data,
|
||||
timestamp,
|
||||
...(timelineCursor
|
||||
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) }
|
||||
: {}),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
};
|
||||
const existingIndex = findExistingTimelineIdentityIndex(state, identity);
|
||||
if (existingIndex < 0) return [...state, nextItem];
|
||||
const existing = state[existingIndex];
|
||||
if (!existing || existing.kind !== "plugin") return state;
|
||||
const next = [...state];
|
||||
next[existingIndex] = {
|
||||
...nextItem,
|
||||
id: existing.id,
|
||||
...(timelineCursor
|
||||
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, existing) }
|
||||
: {}),
|
||||
};
|
||||
next[existingIndex] = { ...nextItem, id: existing.id };
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -1566,7 +1283,7 @@ function appendTodoList(
|
||||
provider: AgentProvider,
|
||||
items: TodoEntry[],
|
||||
timestamp: Date,
|
||||
timelineCursor?: SourcedPosition,
|
||||
timelineCursor?: TimelinePosition,
|
||||
): StreamItem[] {
|
||||
const normalizedItems = items.map((item) => ({
|
||||
text: item.text,
|
||||
@@ -1588,9 +1305,7 @@ function appendTodoList(
|
||||
const next = [...state];
|
||||
next[previousIndex] = {
|
||||
...previous,
|
||||
...(timelineCursor
|
||||
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, previous) }
|
||||
: {}),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
items: normalizedItems,
|
||||
timestamp,
|
||||
};
|
||||
@@ -1609,9 +1324,7 @@ function appendTodoList(
|
||||
const next = [...state];
|
||||
next[next.length - 1] = {
|
||||
...lastItem,
|
||||
...(timelineCursor
|
||||
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor, lastItem) }
|
||||
: {}),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
items: normalizedItems,
|
||||
activity: { type: "created", count: normalizedItems.length },
|
||||
timestamp,
|
||||
@@ -1625,9 +1338,7 @@ function appendTodoList(
|
||||
next.push({
|
||||
kind: "todo_list",
|
||||
id: createUniqueTimelineId(next, "todo", idSeed, timestamp),
|
||||
...(timelineCursor
|
||||
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) }
|
||||
: {}),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
timestamp,
|
||||
provider,
|
||||
items: normalizedItems,
|
||||
@@ -1682,7 +1393,7 @@ function reduceTimelineToolCall(
|
||||
{ type: "tool_call" }
|
||||
>,
|
||||
timestamp: Date,
|
||||
timelineCursor?: SourcedPosition,
|
||||
timelineCursor?: TimelinePosition,
|
||||
): StreamItem[] {
|
||||
const normalizedToolName = item.name
|
||||
.trim()
|
||||
@@ -1753,7 +1464,7 @@ function reduceTimelineCompaction(
|
||||
{ type: "compaction" }
|
||||
>,
|
||||
timestamp: Date,
|
||||
timelineCursor?: SourcedPosition,
|
||||
timelineCursor?: TimelinePosition,
|
||||
): StreamItem[] {
|
||||
if (item.status === "completed") {
|
||||
const loadingIdx = state.findIndex((s) => s.kind === "compaction" && s.status === "loading");
|
||||
@@ -1761,12 +1472,7 @@ function reduceTimelineCompaction(
|
||||
if (loadingIdx >= 0 && existing && existing.kind === "compaction") {
|
||||
const updated: CompactionItem = {
|
||||
...existing,
|
||||
...(timelineCursor
|
||||
? {
|
||||
timelineCursor: anchorOf(timelineCursor),
|
||||
source: sourceFor(timelineCursor, existing),
|
||||
}
|
||||
: {}),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
status: "completed",
|
||||
trigger: item.trigger ?? existing.trigger,
|
||||
preTokens: item.preTokens ?? existing.preTokens,
|
||||
@@ -1780,9 +1486,7 @@ function reduceTimelineCompaction(
|
||||
const compaction: CompactionItem = {
|
||||
kind: "compaction",
|
||||
id: createUniqueTimelineId(state, "compaction", item.status, timestamp),
|
||||
...(timelineCursor
|
||||
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) }
|
||||
: {}),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
timestamp,
|
||||
status: item.status,
|
||||
trigger: item.trigger,
|
||||
@@ -1797,7 +1501,7 @@ function reduceTimelineEvent(
|
||||
timestamp: Date,
|
||||
source: StreamUpdateSource,
|
||||
reservedItemIds?: ReadonlySet<string>,
|
||||
timelineCursor?: SourcedPosition,
|
||||
timelineCursor?: TimelinePosition,
|
||||
): StreamItem[] {
|
||||
const item = event.item;
|
||||
switch (item.type) {
|
||||
@@ -1849,9 +1553,7 @@ function reduceTimelineEvent(
|
||||
kind: "notification",
|
||||
sourceType: "error",
|
||||
id: createUniqueTimelineId(state, "error", item.message ?? "", timestamp),
|
||||
...(timelineCursor
|
||||
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) }
|
||||
: {}),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
timestamp,
|
||||
level: "error",
|
||||
message: item.message ?? "Unknown error",
|
||||
@@ -1863,9 +1565,7 @@ function reduceTimelineEvent(
|
||||
kind: "notification",
|
||||
sourceType: "notification",
|
||||
id: createUniqueTimelineId(state, "notification", item.message, timestamp),
|
||||
...(timelineCursor
|
||||
? { timelineCursor: anchorOf(timelineCursor), source: sourceFor(timelineCursor) }
|
||||
: {}),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
timestamp,
|
||||
level: item.level,
|
||||
message: item.message,
|
||||
@@ -1974,7 +1674,7 @@ export function hydrateStreamState(
|
||||
events: Array<{
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
timelineCursor?: SourcedPosition;
|
||||
timelineCursor?: TimelinePosition;
|
||||
}>,
|
||||
options?: Pick<StreamUpdateOptions, "source" | "reservedItemIds">,
|
||||
): StreamItem[] {
|
||||
@@ -2128,39 +1828,28 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str
|
||||
const firstBlockIndex = activeItem.blockIndex ?? 0;
|
||||
const completedBlocks = blocks.slice(0, -1);
|
||||
const liveBlock = `${blocks[blocks.length - 1] ?? ""}${getTrailingNewlineSuffix(activeItem.text)}`;
|
||||
// Blocks are substrings of the row in order; each keeps the chunks that produced it.
|
||||
const chunks = rowChunks(activeItem);
|
||||
let searchFrom = 0;
|
||||
const blockRow = (block: string, blockIndex: number): AssistantMessageItem => {
|
||||
const start = activeItem.text.indexOf(block, searchFrom);
|
||||
searchFrom = start + block.length;
|
||||
const blockChunks = sliceChunks(chunks, start, start + block.length);
|
||||
return {
|
||||
...activeItem,
|
||||
id: createAssistantBlockId({ groupId: blockGroupId, blockIndex }),
|
||||
blockGroupId,
|
||||
blockIndex,
|
||||
text: block,
|
||||
...(activeItem.timelineCursor && blockChunks.length > 0
|
||||
? {
|
||||
timelineCursor: {
|
||||
epoch: activeItem.timelineCursor.epoch,
|
||||
seq: blockChunks.at(-1)?.seq ?? activeItem.timelineCursor.seq,
|
||||
},
|
||||
source: {
|
||||
startSeq: blockChunks[0]?.seq ?? activeItem.timelineCursor.seq,
|
||||
chunks: blockChunks,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
const promotedItems = completedBlocks.map((block, offset) =>
|
||||
blockRow(block, firstBlockIndex + offset),
|
||||
);
|
||||
const promotedItems = completedBlocks.map<AssistantMessageItem>((block, offset) => ({
|
||||
...activeItem,
|
||||
id: createAssistantBlockId({
|
||||
groupId: blockGroupId,
|
||||
blockIndex: firstBlockIndex + offset,
|
||||
}),
|
||||
blockGroupId,
|
||||
blockIndex: firstBlockIndex + offset,
|
||||
text: block,
|
||||
}));
|
||||
|
||||
const nextTail = flushHeadToTail(params.tail, promotedItems);
|
||||
const liveItem = blockRow(liveBlock, firstBlockIndex + completedBlocks.length);
|
||||
const liveItem: AssistantMessageItem = {
|
||||
...activeItem,
|
||||
id: createAssistantBlockId({
|
||||
groupId: blockGroupId,
|
||||
blockIndex: firstBlockIndex + completedBlocks.length,
|
||||
}),
|
||||
blockGroupId,
|
||||
blockIndex: firstBlockIndex + completedBlocks.length,
|
||||
text: liveBlock,
|
||||
};
|
||||
const nextHead = [
|
||||
...params.head.slice(0, assistantIndex),
|
||||
liveItem,
|
||||
@@ -2256,7 +1945,7 @@ function applyCanonicalUserMessageEvent(params: {
|
||||
head: StreamItem[];
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
timelineCursor?: SourcedPosition;
|
||||
timelineCursor?: TimelinePosition;
|
||||
unmatchedInsert?: "tail" | "head";
|
||||
}): ApplyStreamEventResult | null {
|
||||
const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params;
|
||||
@@ -2272,7 +1961,7 @@ function applyCanonicalUserMessageEvent(params: {
|
||||
messageId: event.item.messageId,
|
||||
clientMessageId: event.item.clientMessageId,
|
||||
turnId: event.turnId,
|
||||
...positionedRow(timelineCursor),
|
||||
timelineCursor,
|
||||
text: normalized.chunk,
|
||||
timestamp,
|
||||
});
|
||||
@@ -2346,7 +2035,7 @@ export function applyStreamEvent(params: {
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
source?: StreamUpdateSource;
|
||||
timelineCursor?: SourcedPosition;
|
||||
timelineCursor?: TimelinePosition;
|
||||
unmatchedUserMessageInsert?: "tail" | "head";
|
||||
}): ApplyStreamEventResult {
|
||||
const { tail, head, event, timestamp } = params;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createTimelineReplica } from "@/timeline/replica";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
@@ -66,26 +65,18 @@ function permission(id: string): AgentPermissionRequest {
|
||||
return { id, provider: "codex", name: id, kind: "tool", title: id };
|
||||
}
|
||||
|
||||
function beginPendingSubmission(serverId: string, agentId: string) {
|
||||
function beginPendingSubmission(serverId: string, agentId: string): string {
|
||||
const clientMessageId = `client-${agentId}`;
|
||||
const replica = createTimelineReplica({
|
||||
useSessionStore.getState().beginAgentMessageSubmission(
|
||||
serverId,
|
||||
storage: {
|
||||
removeTimeline: () => undefined,
|
||||
readTimeline: async () => undefined,
|
||||
commitTimeline: () => undefined,
|
||||
},
|
||||
});
|
||||
replica.beginSubmission(
|
||||
agentId,
|
||||
createUserMessage({
|
||||
clientMessageId,
|
||||
text: "Run this",
|
||||
timestamp: new Date("2026-07-27T10:00:00.000Z"),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
return { clientMessageId, replica };
|
||||
return clientMessageId;
|
||||
}
|
||||
|
||||
describe("message submission authority", () => {
|
||||
@@ -94,11 +85,9 @@ describe("message submission authority", () => {
|
||||
const agentId = "agent-1";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const { clientMessageId } = beginPendingSubmission(serverId, agentId);
|
||||
const clientMessageId = beginPendingSubmission(serverId, agentId);
|
||||
const agent = createAgentPayload({ id: agentId, status: "running" });
|
||||
new AgentStoreProjection(serverId, (removedAgentId) =>
|
||||
useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId),
|
||||
).applyDelta({
|
||||
new AgentStoreProjection(serverId).applyDelta({
|
||||
kind: "upsert",
|
||||
agent,
|
||||
project: createEntry(agent).project,
|
||||
@@ -119,44 +108,28 @@ describe("message submission authority", () => {
|
||||
const agentId = "agent-1";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const { clientMessageId, replica } = beginPendingSubmission(serverId, agentId);
|
||||
replica.handoffSubmission(
|
||||
agentId,
|
||||
createUserMessage({
|
||||
id: "provider-message",
|
||||
messageId: "provider-message",
|
||||
clientMessageId,
|
||||
text: "Run this",
|
||||
timestamp: new Date("2026-07-27T10:00:01.000Z"),
|
||||
}),
|
||||
);
|
||||
const clientMessageId = beginPendingSubmission(serverId, agentId);
|
||||
store.setAgentStreamState(serverId, agentId, {
|
||||
tail: [
|
||||
createUserMessage({
|
||||
id: "provider-message",
|
||||
messageId: "provider-message",
|
||||
clientMessageId,
|
||||
text: "Run this",
|
||||
timestamp: new Date("2026-07-27T10:00:01.000Z"),
|
||||
}),
|
||||
],
|
||||
head: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0]
|
||||
?.providerAcknowledged,
|
||||
).toBe(false);
|
||||
|
||||
replica.applyEvents(
|
||||
agentId,
|
||||
[
|
||||
{
|
||||
seq: 1,
|
||||
epoch: "epoch",
|
||||
timestamp: new Date("2026-07-27T10:00:01.000Z"),
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "user_message",
|
||||
messageId: "provider-message",
|
||||
clientMessageId,
|
||||
text: "Run this",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
() => undefined,
|
||||
);
|
||||
store.setAgentStreamState(serverId, agentId, {
|
||||
acknowledgedClientMessageIds: [clientMessageId],
|
||||
});
|
||||
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0]
|
||||
@@ -173,9 +146,9 @@ describe("replaceFetchedAgentDirectory", () => {
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
store.setInitializingAgents(serverId, new Map([["agent", true]]));
|
||||
|
||||
new AgentStoreProjection(serverId, (removedAgentId) =>
|
||||
useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId),
|
||||
).replaceFetched([createEntry(createAgentPayload({ id: "agent" }))]);
|
||||
new AgentStoreProjection(serverId).replaceFetched([
|
||||
createEntry(createAgentPayload({ id: "agent" })),
|
||||
]);
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get("agent")).toBe(
|
||||
true,
|
||||
@@ -188,9 +161,7 @@ describe("replaceFetchedAgentDirectory", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
|
||||
const projection = new AgentStoreProjection(serverId, (removedAgentId) =>
|
||||
useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId),
|
||||
);
|
||||
const projection = new AgentStoreProjection(serverId);
|
||||
projection.replaceFetched([
|
||||
createEntry(
|
||||
createAgentPayload({
|
||||
@@ -231,15 +202,10 @@ describe("replaceFetchedAgentDirectory", () => {
|
||||
serverId,
|
||||
new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]),
|
||||
);
|
||||
store.applyAgentTimelineResponseState(serverId, agentId, {
|
||||
items: [],
|
||||
head: [],
|
||||
range: { epoch: "epoch", startSeq: 1, endSeq: 2 },
|
||||
older: "none",
|
||||
newer: false,
|
||||
synchronized: false,
|
||||
submissions: [],
|
||||
});
|
||||
store.setAgentTimelineCursor(
|
||||
serverId,
|
||||
new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]),
|
||||
);
|
||||
store.setPendingPermissions(
|
||||
serverId,
|
||||
new Map([["permission", { key: "permission", agentId, request: null as never }]]),
|
||||
@@ -247,9 +213,7 @@ describe("replaceFetchedAgentDirectory", () => {
|
||||
store.setInitializingAgents(serverId, new Map([[agentId, true]]));
|
||||
setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true });
|
||||
|
||||
new AgentStoreProjection(serverId, (removedAgentId) =>
|
||||
useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId),
|
||||
).applyDelta({ kind: "remove", agentId });
|
||||
new AgentStoreProjection(serverId).applyDelta({ kind: "remove", agentId });
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
expect({
|
||||
@@ -309,9 +273,7 @@ describe("replaceFetchedAgentDirectory", () => {
|
||||
lastUsage: { inputTokens: 10, outputTokens: 5 },
|
||||
pendingPermissions: [permission("current-permission")],
|
||||
});
|
||||
const projection = new AgentStoreProjection(serverId, (removedAgentId) =>
|
||||
useSessionStore.getState().removeAgentTimeline(serverId, removedAgentId),
|
||||
);
|
||||
const projection = new AgentStoreProjection(serverId);
|
||||
projection.applyDelta({
|
||||
kind: "upsert",
|
||||
agent: current,
|
||||
|
||||
@@ -10,9 +10,6 @@ export type AgentDirectoryDelta = Extract<
|
||||
{ type: "agent_update" }
|
||||
>["payload"];
|
||||
|
||||
// Directory membership can disappear without the underlying agent being deleted.
|
||||
export type AgentRemovalReason = "scope" | "deleted";
|
||||
|
||||
interface PendingPermissionEntry {
|
||||
key: string;
|
||||
agentId: string;
|
||||
|
||||
Reference in New Issue
Block a user