Compare commits

...
Author SHA1 Message Date
Mohamed Boudra ef19db587e fix(app): keep hidden chats live while a subscription is replaced
The old timeline subscription was released in the same tick the new one
was requested. The client deletes a route on release and adds the new
route only when the daemon responds, so events sent in between were
dropped. Visible chats recover through the cursor check; hidden chats
skip it and stayed behind until something else fetched them.

Release the old subscription when the new one settles. Accepting the new
route and releasing the old one happen without a socket frame in
between, so each event reaches the client once.
2026-11-09 10:51:22 +01:00
Mohamed Boudra 130dec624d test(app): pin the held running turn across a freshness check
A chat that left mid-turn holds its running footer until its messages are
current. With the freshness check, "current" is that commit with no rows, so
the tests and the timeline-sync doc now cover the check in flight, a current
answer, and a behind answer that holds until the fetched rows land.
2026-11-09 09:53:55 +01:00
Mohamed Boudra 279211e110 fix(app): keep agent details and stay quiet after a current timeline answer
The timeline fetch that a current answer skips also delivered the full agent
row, and the replica cache stores only part of it. After a cold reload with
an empty incremental directory page, usage, modes, permissions and runtime
info stayed empty. The subscribe answer now carries the row for each current
chat, gated with the rest of the freshness check.

The panel also reported ready before the timeline owner was published, so a
connected render with cached content showed Updating messages. No owner now
reads as a check that has not begun.
2026-11-09 09:48:36 +01:00
Mohamed Boudra 44b608b854 fix(app): stop showing Updating messages for a chat that has not changed
Every return to a chat fetched from the daemon just to learn nothing had
changed, and the toast covered that fetch. The subscribe request that
attaches a chat now carries its cursor, and the daemon answers current or
behind in the same round trip, so an unchanged chat needs no fetch and
shows nothing.

The daemon attaches before it compares, so a commit landing in between is
either delivered live or counted as behind. It never loads an agent to
answer: an agent it has not loaded answers behind and resumes in the
fetch, as before.

The delay before an unanswered check shows the toast starts when the
request is sent. A cold start's cache read alone can outlast the delay,
which would flash the toast for a chat the daemon then reports current.
2026-11-09 09:48:36 +01:00
Mohamed Boudra b2b82e5cb0 Keep the running footer until a chat's messages are current (#9170)
* fix(app): change the turn footer together with its messages

The footer followed the agent directory while the messages followed the
timeline catch-up, so a chat returning to a turn that ended in the
background applied the idle snapshot first: the running footer vanished
or a completed footer sat under stale messages until the catch-up landed.

The timeline now holds the turn its messages were captured during, and
releases it in the same store write as the rows that bring the chat
current. While held, the footer shows an updating state in the running
footer's slot. The sidebar, tab and composer keep reading the true status.

Losing the connection does not release the hold. No catch-up can run or
fail until the host returns, and releasing it would put a completed
footer under messages known to be behind.

* test(app): expect the updating flag on submission-only activity

* fix(app): keep a failed catch-up from restoring the updating footer

A subscription failure could land while the chat's cache read was still
pending. The failure released the held turn, then the read restored the
turn saved with the messages, and no catch-up remained to release it, so
the chat showed a sync error beside "Updating...".

The replica writes the held turn from the cache, so it now also hears
when a catch-up settles or is owed again, and a read in flight respects it.

* fix(app): keep the running footer until a chat's messages are current

A separate catching-up footer added a visible state nobody asked for. A
chat that is behind now keeps showing the running turn it already shows,
so the idle status changes nothing on screen until the messages land with
the completed footer.

The held turn carries its start time, and saves it with the cached
messages, so the elapsed timer keeps counting from the turn's real start
through the hold and on first paint from cache.

* fix(app): let a newer turn's timer replace a held one on hosts without turn ids

Old hosts report a null turn id for every turn, so comparing ids alone kept the older turn's start time and the held footer counted from it until the chat caught up.
2026-11-09 15:47:29 +07:00
Mohamed Boudra 180f07587a fix(app): let composer pills scroll sideways on Android (#9162)
The strip of pills above the composer started a drag on Android but never
moved. Its ScrollView had pointerEvents="box-none", carried over from the
plain View it replaced. React Native's Android horizontal scroll view
accepts the drag in onInterceptTouchEvent and then drops every onTouchEvent
unless the view itself can be a touch target, which box-none rules out.

box-none was there so the empty part of the row passes touches to the
transcript. The scroller now sizes to its pills instead, capped at the bar
because web would otherwise let it grow past the bar, so the empty part of
the row is outside it.

Adds the edge fades the workspace tab row uses, so pills cut off at the
edge read as scrollable. The fades reach the bottom edge here because each
pill's own border does.
2026-11-09 12:12:18 +07:00
36 changed files with 2341 additions and 115 deletions
+38 -8
View File
@@ -102,15 +102,45 @@ updated after the checkpoint does not block it. Forking the current context rema
## Resume behavior
Opening, reconnecting, and returning from app background establish the current timeline through
bounded catch-up. Switching between continuously subscribed open chats needs no fetch.
Focus alone does not mutate timeline state; the response is compared with the local
authoritative range first.
Opening, reconnecting, and returning from app background establish whether the chat is current.
Switching between continuously subscribed open chats needs no fetch. Focus alone does not mutate
timeline state; the response is compared with the local authoritative range first.
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host is
offline, then Updating messages until authoritative catch-up completes. Socket connectivity alone cannot
certify that the displayed conversation is current. The timeline owner publishes freshness; the
view renders it without a toast timer or a separate resume workflow.
A chat with a cached cursor asks the daemon first. The `agent.timeline.set_subscription.request`
that attaches the chat carries its `{ epoch, seq }`, and the response answers `current` or `behind`
per chat. The daemon attaches before it compares, so an entry committed in between arrives live or
counts as behind. `current` means the agent is loaded, the epoch matches, and the cursor is at the
head; it completes catch-up with no fetch. The response carries the agent row for each `current`
chat, because the timeline fetch is also what refreshes that row and the replica cache stores only
part of it. `behind`, or no entry, runs the bounded catch-up. The subscribe handler never loads an
agent, so an agent the daemon has not loaded answers `behind` and resumes in that fetch. Hosts without `features.timelineFreshnessCheck` get no cursors and always fetch.
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host
is offline and until a reconnected chat's check answers, so an unchanged chat never shows a second
toast. A check on cached content shows nothing. Updating messages appears for a chat that is behind,
until the fetch completes, and for a check still unanswered `FRESHNESS_CHECK_QUIET_MS` after it was
sent. That delay measures the daemon, so it starts at the request: on a cold start the cache read
alone can take longer. Socket connectivity alone cannot certify that the displayed conversation is
current. The timeline owner publishes freshness; the view renders it without a toast timer or a
separate resume workflow.
The turn footer in a chat follows its messages, not the agent status. Turn activity comes from the
agent directory and messages from the chat's own catch-up, and either can land first. So the
timeline owner keeps an unsettled turn per chat: the turn its messages were captured during. It is
set when a check or catch-up becomes owed while the turn is open, or when cached messages are
painted that were saved while one was, and it is saved with those messages in the replica. It
carries the turn's start time. While it is set and the agent reads idle, the chat keeps showing that
running turn, with its loader and elapsed timer unchanged, and there is no separate catching-up
footer. The commit that brings the chat current clears it, in the same store write as the rows, so
the final messages and the completed footer land together. A `current` answer is that commit with no
rows. A check in flight keeps the hold, and `behind` keeps it until the fetched rows land. A failed
catch-up clears it too, and the footer takes the true status. Losing the connection is not a failed
catch-up: none can run or fail until the host returns, so the running footer stays beside
"Reconnecting to host" and the check on reconnect resolves it. Releasing it offline would put a
completed footer under messages known to be behind. Surfaces outside the timeline (the sidebar,
tabs, the composer's stop and send controls) read the true status and never see this state. Live
delivery needs no hold because `applyStreamEvent` commits queued rows before it applies turn
liveness.
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
@@ -13,10 +13,17 @@ import {
import {
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectOneResumeCheckWithoutTail,
expectResumeAnsweredWithoutFetch,
expectResumeOverflowFallsBackToOneTail,
hideTabAndReturn,
rememberTimelineRequestCounts,
releaseHeldCatchUp,
restoreUnchangedViewedTimeline,
restoreViewedTimelineHoldingCatchUp,
restoreViewedTimelineWithHeldResponse,
waitForResumeAnswer,
reopenAppWatchingForUpdatingToast,
watchForUpdatingToast,
} from "../support/helpers/timeline-resume";
test.describe("Agent timeline resume", () => {
@@ -42,7 +49,9 @@ test.describe("Agent timeline resume", () => {
}
});
test("an unchanged resume leaves a scrolled-up timeline untouched", async ({ page }) => {
test("returning to an unchanged chat shows no update and leaves it untouched", async ({
page,
}) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 30 });
@@ -51,13 +60,65 @@ test.describe("Agent timeline resume", () => {
await expectTimelinePromptVisible(page, agent.newestPrompt);
await scrollTimelinePromptIntoView(page, agent.initialTailAnchorPrompt);
const presentation = await rememberTimelinePresentation(page, agent.initialTailAnchorPrompt);
const requests = rememberTimelineRequestCounts(gate);
await test.step("back from the background", async () => {
const requests = rememberTimelineRequestCounts(gate);
const expectNoUpdatingToast = await watchForUpdatingToast(page);
await hideTabAndReturn(page);
await waitForResumeAnswer(gate, requests);
await expectNoUpdatingToast();
await expectTimelinePresentationUnchanged(page, presentation);
expectResumeAnsweredWithoutFetch(gate, requests);
});
await test.step("back from a dropped connection", async () => {
const requests = rememberTimelineRequestCounts(gate);
const expectNoUpdatingToast = await watchForUpdatingToast(page);
await disconnectViewedTimeline(page, gate);
await restoreUnchangedViewedTimeline(page, gate);
await expectNoUpdatingToast();
await expectTimelinePresentationUnchanged(page, presentation);
expectResumeAnsweredWithoutFetch(gate, requests);
});
await test.step("back from reopening the app", async () => {
const requests = rememberTimelineRequestCounts(gate);
const expectNoUpdatingToast = await reopenAppWatchingForUpdatingToast(page);
await waitForResumeAnswer(gate, requests);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectNoUpdatingToast();
expectResumeAnsweredWithoutFetch(gate, requests);
});
} finally {
gate.restore();
await agent.cleanup();
}
});
test("returning to a changed chat reports the update and shows the new messages", async ({
page,
}) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 3 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
const presentation = await rememberTimelinePresentation(page, agent.newestPrompt);
await disconnectViewedTimeline(page, gate);
await restoreViewedTimelineWithHeldResponse(page, gate);
const background = await commitTimelineTurnsWhileDisconnected(agent, 1);
await restoreViewedTimelineHoldingCatchUp(page, gate);
await expectTimelinePresentationUnchanged(page, presentation);
expectOneResumeCheckWithoutTail(gate, requests);
await releaseHeldCatchUp(page, gate);
await expectTimelinePromptVisible(page, background.newestPrompt);
} finally {
gate.restore();
await agent.cleanup();
@@ -6,6 +6,7 @@ import {
seedLongMockAgentTimeline,
} from "../support/helpers/timeline-pagination";
import {
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
restoreViewedTimelineWithHeldResponse,
} from "../support/helpers/timeline-resume";
@@ -25,6 +26,7 @@ test.describe("Agent timeline sync retry", () => {
gate.failTimelineResponses(agent.agentId);
await disconnectViewedTimeline(page, gate);
await commitTimelineTurnsWhileDisconnected(agent, 1);
await restoreViewedTimelineWithHeldResponse(page, gate);
const callout = page.getByTestId("agent-timeline-sync-error");
+204 -6
View File
@@ -21,7 +21,10 @@ interface Box {
interface TurnEndFrame {
at: number;
running: boolean;
replyLength: number;
footer: Box | null;
footerText: string;
elapsed: string | null;
userRow: Box | null;
replyVisible: boolean;
}
@@ -61,10 +64,13 @@ async function recordTurnEndFrames(
const viewport = Array.from(
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
).find((candidate) => isVisible(candidate));
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
.map(boxOf)
.filter((box): box is Box => box !== null)
.sort((left, right) => left.top - right.top);
const footer = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
.map((element) => ({ element, box: boxOf(element) }))
.filter((row): row is { element: Element; box: Box } => row.box !== null)
.sort((left, right) => left.box.top - right.box.top)
.at(-1);
const elapsed =
viewport?.querySelector('[data-testid="turn-working-elapsed"]')?.textContent ?? null;
const userRow = Array.from(
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
).find((candidate) => candidate.textContent?.includes(prompt));
@@ -74,10 +80,16 @@ async function recordTurnEndFrames(
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
(candidate) => candidate.textContent?.includes(reply),
);
const assistantRows = Array.from(
viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? [],
);
state.frames.push({
at: performance.now(),
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
footer: footers.at(-1) ?? null,
replyLength: assistantRows.reduce((sum, row) => sum + (row.textContent?.length ?? 0), 0),
footer: footer?.box ?? null,
footerText: (footer?.element.textContent ?? "").replace(elapsed ?? "", ""),
elapsed,
userRow: boxOf(userRow),
replyVisible: boxOf(replyRow) !== null,
});
@@ -147,7 +159,7 @@ function describeFrames(frames: TurnEndFrame[]): string {
(frame, index) =>
`${index}: ${frame.running ? "running " : "complete"} footer=${
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
} timer=${frame.elapsed ?? "none"} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
)
.join("\n");
}
@@ -182,6 +194,192 @@ function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | nu
).toBeLessThanOrEqual(1);
}
interface ReturnedTurn {
frames: TurnEndFrame[];
reconnectedAt: number;
finalReplyLength: number;
}
// The agent finishes while the socket is down, then the catch-up is held so the idle status
// reaches the chat first. Painted frames are sampled from the running footer until everything
// has settled.
async function returnAfterTurnFinished(input: {
page: Page;
agent: MockAgentWorkspace;
prompt: string;
leaveWhile: (gate: DaemonWebSocketGate) => Promise<void>;
}): Promise<ReturnedTurn> {
const { page, agent, prompt, leaveWhile } = input;
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await recordTurnEndFrames(page, { prompt, reply: "" });
await leaveWhile(gate);
gate.holdTimelineResponses(agent.agentId);
const reconnectedAt = await page.evaluate(() => performance.now());
gate.restore();
await gate.waitForHeldTimelineResponse();
await expectAgentIdle(page);
await recordPaintsFor(page, 2_000);
gate.releaseHeldTimelineResponses();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expect(page.getByTestId("turn-footer").last()).toBeVisible();
await recordPaintsFor(page, 500);
const frames = await stopRecording(page);
return { frames, reconnectedAt, finalReplyLength: frames.at(-1)?.replyLength ?? 0 };
}
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
async function leaveWithOnlyThePromptOnScreen(
page: Page,
agent: MockAgentWorkspace,
gate: DaemonWebSocketGate,
prompt: string,
): Promise<void> {
gate.setAgentStreamItemSuppressed("assistant_message", true);
gate.holdNextAgentStreamEvent("turn_completed");
gate.holdNextAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", true);
await submitMessage(page, prompt);
await Promise.all([
gate.waitForHeldAgentStreamEvent("turn_completed"),
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
]);
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await gate.drop();
await agent.client.waitForFinish(agent.agentId, 15_000);
gate.setAgentStreamItemSuppressed("assistant_message", false);
gate.setServerMessageSuppressed("agent_update", false);
}
async function leaveWithAPartialReplyOnScreen(
page: Page,
agent: MockAgentWorkspace,
gate: DaemonWebSocketGate,
prompt: string,
): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await gate.drop();
await agent.client.waitForFinish(agent.agentId, 30_000);
}
function elapsedSeconds(frame: TurnEndFrame): number {
const units: Record<string, number> = { h: 3600, m: 60, s: 1 };
return [...(frame.elapsed ?? "").matchAll(/(\d+)([hms])/g)].reduce(
(total, [, value, unit]) => total + Number(value) * units[unit],
0,
);
}
function expectFooterUnchangedUntilMessagesLand(
{ frames, reconnectedAt }: ReturnedTurn,
record: string,
): void {
const held = frames.slice(
frames.findIndex((frame) => frame.at >= reconnectedAt),
frames.findIndex((frame) => frame.at >= reconnectedAt && !frame.running),
);
expect(
held.length,
`the running footer must hold until the messages land\n${record}`,
).toBeGreaterThan(0);
expect(
new Set(held.map((frame) => `${frame.footer?.top}+${frame.footer?.height}`)).size,
`the held footer must not move or resize\n${record}`,
).toBe(1);
expect(
new Set(held.map((frame) => frame.footerText)).size,
`the held footer's content must not change\n${record}`,
).toBe(1);
expect(
held.filter((frame) => frame.elapsed === null).map((frame) => frame.at),
`the timer must stay on screen\n${record}`,
).toEqual([]);
const seconds = held.map(elapsedSeconds);
expect(seconds, `the timer must keep counting up\n${record}`).toEqual(
seconds.toSorted((left, right) => left - right),
);
}
function expectReturnLandsWhole(returned: ReturnedTurn): void {
const { frames, finalReplyLength } = returned;
const record = `frames:\n${describeFrames(frames)}`;
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
const fromRunning = frames.slice(firstRunning);
expect(
fromRunning.filter((frame) => frame.footer === null).map((frame) => frame.at),
`the footer must never disappear\n${record}`,
).toEqual([]);
const completed = fromRunning.filter((frame) => !frame.running);
expect(completed.length, record).toBeGreaterThan(0);
// The first Markdown on a page paints an empty row until the web parser loads. Only text that
// is present and short of the final reply is stale.
expect(
completed
.filter((frame) => frame.replyLength > 0 && frame.replyLength !== finalReplyLength)
.map((frame) => frame.at),
`a completed footer must never sit under stale messages\n${record}`,
).toEqual([]);
expectFooterUnchangedUntilMessagesLand(returned, record);
}
async function withFirstTurnAgent(
model: string,
run: (agent: MockAgentWorkspace) => Promise<void>,
): Promise<void> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "turn-return-",
title: "Turn return",
model,
});
try {
await run(agent);
} finally {
await agent.cleanup();
}
}
test.describe("Returning to a turn that finished while away", () => {
test("keeps the running footer until the messages land when only the prompt was on screen", async ({
page,
}) => {
await withFirstTurnAgent("e2e-fast-stream", async (agent) => {
const prompt = "emit 1 coalesced agent stream updates";
const returned = await returnAfterTurnFinished({
page,
agent,
prompt,
leaveWhile: (gate) => leaveWithOnlyThePromptOnScreen(page, agent, gate, prompt),
});
expectReturnLandsWhole(returned);
});
});
test("keeps the running footer until the messages land under a partial reply", async ({
page,
}) => {
await withFirstTurnAgent("ten-second-stream", async (agent) => {
const prompt = "Return partial prompt";
const returned = await returnAfterTurnFinished({
page,
agent,
prompt,
leaveWhile: (gate) => leaveWithAPartialReplyOnScreen(page, agent, gate, prompt),
});
expectReturnLandsWhole(returned);
});
});
});
test.describe("Turn end", () => {
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
@@ -5,6 +5,7 @@ import {
type IsolatedHostDaemon,
} from "../support/helpers/isolated-host-daemon";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
import { setTabVisibility } from "../support/helpers/workspace-ui";
const test = base.extend<{ reconnectHost: IsolatedHostDaemon }>({
reconnectHost: async ({ e2eWorker }, provide) => {
@@ -37,17 +38,6 @@ async function refocusConnectedTab(page: Page): Promise<void> {
await expect(page.getByText("Online", { exact: true })).toBeVisible();
}
async function setTabVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
await page.evaluate((state) => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => state === "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
}, visibility);
}
async function openHostOverview(page: Page, daemon: IsolatedHostDaemon): Promise<void> {
await page.goto("/");
await seedSavedSettingsHosts(page, [
@@ -300,15 +300,11 @@ test("clicking the already focused timeline acknowledges a new completion", asyn
async function reconnectWithoutInteracting(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
agentId: string,
) {
await gate.drop();
await gate.waitForBlockedConnection();
gate.holdTimelineResponses(agentId);
gate.restore();
await gate.waitForHeldTimelineResponse();
await expectReconnectingToastGone(page);
gate.releaseHeldTimelineResponses();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
@@ -325,7 +321,7 @@ test("reconnecting a focused chat preserves its unread completion until another
);
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
await reconnectWithoutInteracting(page, gate, workspaces.subject.agentId);
await reconnectWithoutInteracting(page, gate);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
await page.getByText("Unread across reconnect.", { exact: true }).click();
await expectStatus(page, workspaces.subject.workspaceId, "done");
@@ -48,7 +48,13 @@ export function trackPluginChatRecovery(page: Page) {
async expectRecoveredWithoutRetransmission() {
await this.expectCatalogWithoutRetransmission();
await expect
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
.poll(() =>
messages.some(
(message) =>
message.type === "fetch_agent_timeline_response" ||
message.type === "agent.timeline.set_subscription.response",
),
)
.toBe(true);
},
};
@@ -1,14 +1,25 @@
import { expect, type Page } from "@playwright/test";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import type { LongTimelineAgent } from "./timeline-pagination";
import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./workspace-ui";
import {
expectReconnectingToastGone,
expectReconnectingToastVisible,
setTabVisibility,
} from "./workspace-ui";
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
const SUBSCRIPTION_REQUEST = "agent.timeline.set_subscription.request";
const SUBSCRIPTION_RESPONSE = "agent.timeline.set_subscription.response";
const TIMELINE_REQUEST = "fetch_agent_timeline_request";
const TIMELINE_RESPONSE = "fetch_agent_timeline_response";
export interface TimelineRequestCounts {
agentId?: string;
after: number;
tail: number;
subscriptions: number;
fetches: number;
}
export interface BackgroundTimelineTurns {
@@ -42,17 +53,89 @@ export function rememberTimelineRequestCounts(
agentId,
after: gate.getTimelineRequestCount("after", agentId),
tail: gate.getTimelineRequestCount("tail", agentId),
subscriptions: gate.getClientRequestCount(SUBSCRIPTION_REQUEST),
fetches: gate.getClientRequestCount(TIMELINE_REQUEST),
};
}
export function expectOneResumeCheckWithoutTail(
export function expectResumeAnsweredWithoutFetch(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
expect(gate.getTimelineRequestCount("after", before.agentId) - before.after).toBe(1);
expect(gate.getTimelineRequestCount("after", before.agentId) - before.after).toBe(0);
expect(gate.getTimelineRequestCount("tail", before.agentId) - before.tail).toBe(0);
}
/** Resolves once the daemon has told the app where this chat stands, whichever way it asked. */
export async function waitForResumeAnswer(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): Promise<void> {
await Promise.race([
gate.waitForServerMessage(SUBSCRIPTION_RESPONSE, before.subscriptions + 1),
gate.waitForServerMessage(TIMELINE_RESPONSE, before.fetches + 1),
]);
}
/** Records every appearance of the toast, including one that lasts a single commit. */
export async function watchForUpdatingToast(page: Page): Promise<() => Promise<void>> {
const watch = await page.evaluateHandle(() => {
let shown = false;
const check = () => {
shown ||= document.querySelector('[data-testid="agent-updating-toast"]') !== null;
};
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
check();
return {
stop() {
observer.disconnect();
return shown;
},
};
});
return async () => {
await waitForNextPaint(page);
const shown = await watch.evaluate((recorder) => recorder.stop());
await watch.dispose();
expect(shown, "the Updating messages toast appeared").toBe(false);
};
}
/** Reopens the app on this chat, recording the toast from the first paint of the new page. */
export async function reopenAppWatchingForUpdatingToast(page: Page): Promise<() => Promise<void>> {
await page.addInitScript(() => {
const recorder = window as Window & { updatingToastShown?: boolean };
new MutationObserver(() => {
recorder.updatingToastShown ||=
document.querySelector('[data-testid="agent-updating-toast"]') !== null;
}).observe(document, { childList: true, subtree: true });
});
await page.reload();
return async () => {
await waitForNextPaint(page);
const shown = await page.evaluate(
() => (window as Window & { updatingToastShown?: boolean }).updatingToastShown === true,
);
expect(shown, "the Updating messages toast appeared").toBe(false);
};
}
async function waitForNextPaint(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
export async function hideTabAndReturn(page: Page): Promise<void> {
await setTabVisibility(page, "hidden");
await waitForNextPaint(page);
await setTabVisibility(page, "visible");
}
export function expectResumeOverflowFallsBackToOneTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
@@ -69,15 +152,34 @@ export async function disconnectViewedTimeline(
await expectReconnectingToastVisible(page);
}
export async function restoreViewedTimelineWithHeldResponse(
export async function restoreViewedTimelineHoldingCatchUp(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
gate.holdNextServerMessage("fetch_agent_timeline_response");
gate.holdNextServerMessage(TIMELINE_RESPONSE);
gate.restore();
await gate.waitForHeldServerMessage();
await expectReconnectingToastGone(page);
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
}
export async function releaseHeldCatchUp(page: Page, gate: DaemonWebSocketGate): Promise<void> {
gate.releaseHeldServerMessage();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
export async function restoreViewedTimelineWithHeldResponse(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
await restoreViewedTimelineHoldingCatchUp(page, gate);
await releaseHeldCatchUp(page, gate);
}
export async function restoreUnchangedViewedTimeline(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
gate.restore();
await expectReconnectingToastGone(page);
}
@@ -91,6 +91,20 @@ export async function expectWorkspaceHeader(
});
}
export async function setTabVisibility(
page: Page,
visibility: DocumentVisibilityState,
): Promise<void> {
await page.evaluate((state) => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => state === "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
}, visibility);
}
export async function expectReconnectingToastVisible(
page: Page,
options?: { timeout?: number },
@@ -74,12 +74,15 @@ export function HorizontalScrollBoundaryShades({
leftStyle,
rightStyle,
testIDPrefix = "horizontal-scroll-boundary",
bottomInset = 1,
}: {
visible: boolean;
backdrop: "surface" | "sidebar";
leftStyle: AnimatedStyle<{ opacity: number }>;
rightStyle: AnimatedStyle<{ opacity: number }>;
testIDPrefix?: string;
/** Set to 0 when the scrolled content's own border reaches the bottom edge and must fade too. */
bottomInset?: number;
}) {
if (!visible) return null;
const colorMapping = backdrop === "sidebar" ? sidebarColorMapping : surfaceColorMapping;
@@ -88,14 +91,14 @@ export function HorizontalScrollBoundaryShades({
<Animated.View
pointerEvents="none"
testID={`${testIDPrefix}-left`}
style={[styles.shade, styles.left, leftStyle]}
style={[styles.shade(bottomInset), styles.left, leftStyle]}
>
<ThemedScrollBoundaryShadeSvg side="left" uniProps={colorMapping} />
</Animated.View>
<Animated.View
pointerEvents="none"
testID={`${testIDPrefix}-right`}
style={[styles.shade, styles.right, rightStyle]}
style={[styles.shade(bottomInset), styles.right, rightStyle]}
>
<ThemedScrollBoundaryShadeSvg side="right" uniProps={colorMapping} />
</Animated.View>
@@ -104,12 +107,12 @@ export function HorizontalScrollBoundaryShades({
}
const styles = StyleSheet.create(() => ({
shade: {
shade: (bottomInset: number) => ({
position: "absolute",
top: 0,
bottom: 1,
bottom: bottomInset,
width: SHADE_WIDTH,
},
}),
left: { left: 0 },
right: { right: 0 },
}));
@@ -1,9 +1,15 @@
import React, { act, useCallback, useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { Pressable, Text } from "react-native";
import { Pressable, Text, View } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks";
import { MobilePanelsProvider } from "@/mobile-panels/provider";
import {
ComposerTrackBar,
ComposerTrackPill,
ComposerTrackRow,
type ComposerTrackPillSegment,
} from "./tracks";
const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }];
@@ -192,6 +198,87 @@ describe("composer track panel", () => {
});
});
const BAR_PILL_STYLE = { width: 120, height: 32 };
const TRANSCRIPT_STYLE = { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 } as const;
const NARROW_HOST = { position: "relative", width: 300, height: 120 } as const;
const WIDE_HOST = { position: "relative", width: 400, height: 120 } as const;
function mountTrackBar({
host,
pills,
}: {
host: typeof NARROW_HOST | typeof WIDE_HOST;
pills: number;
}): void {
mount(
<MobilePanelsProvider>
<View style={host}>
<View testID="transcript" style={TRANSCRIPT_STYLE} />
<ComposerTrackBar>
{Array.from({ length: pills }, (_, index) => (
<View key={index} testID={`bar-pill-${index}`} style={BAR_PILL_STYLE} />
))}
</ComposerTrackBar>
</View>
</MobilePanelsProvider>,
);
}
function byTestID(testID: string): HTMLElement {
const element = document.querySelector(`[data-testid="${testID}"]`);
if (!(element instanceof HTMLElement)) {
throw new Error(`${testID} did not render`);
}
return element;
}
/** The element that receives a touch landing at this point of the page. */
function hitTest(x: number, y: number): Element | null {
return document.elementFromPoint(x, y);
}
describe("composer track bar", () => {
// Android's horizontal ScrollView ignores the drag unless the scroller itself can be touched.
// Browsers scroll either way, so this is the only part of that bug a browser can see.
it("keeps the scroller touchable, so Android can drag the row", () => {
mountTrackBar({ host: NARROW_HOST, pills: 4 });
expect(getComputedStyle(byTestID("composer-track-scroll")).pointerEvents).toBe("auto");
});
it("stays inside the bar when the pills overflow it", () => {
mountTrackBar({ host: NARROW_HOST, pills: 4 });
const scroller = byTestID("composer-track-scroll");
expect(scroller.getBoundingClientRect().right).toBeLessThanOrEqual(
byTestID("transcript").getBoundingClientRect().right,
);
expect(scroller.scrollWidth).toBeGreaterThan(scroller.clientWidth);
});
it("leaves the empty part of the row to the transcript", () => {
mountTrackBar({ host: WIDE_HOST, pills: 1 });
const pillRect = byTestID("bar-pill-0").getBoundingClientRect();
const transcript = byTestID("transcript");
const emptyX = transcript.getBoundingClientRect().right - 40;
expect(emptyX).toBeGreaterThan(pillRect.right);
expect(hitTest(emptyX, pillRect.top + pillRect.height / 2)).toBe(transcript);
});
it("fades the full height of the row without taking its touches", () => {
mountTrackBar({ host: NARROW_HOST, pills: 4 });
const scroller = byTestID("composer-track-scroll").getBoundingClientRect();
const shade = byTestID("composer-track-scroll-shade-right").getBoundingClientRect();
expect(shade.bottom).toBe(scroller.bottom);
const underShade = hitTest(shade.left + shade.width / 2, shade.top + shade.height / 2);
expect(byTestID("composer-track-scroll").contains(underShade)).toBe(true);
});
});
describe("composer track pill status mark", () => {
/** The mark is the glyph itself: the dot for a still state, the ring's carrier for running. */
function mountMark(bucket: "running" | "failed"): { mark: HTMLElement; body: HTMLElement } {
+29 -4
View File
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState, type ReactElement, type ReactNode } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import {
MENU_ITEM_HEIGHT,
@@ -15,6 +16,10 @@ import {
} from "@/components/ui/menu";
import { StatusRing } from "@/components/status-ring";
import { bySizing } from "@/components/ui/control-geometry";
import {
HorizontalScrollBoundaryShades,
useHorizontalScrollBoundary,
} from "@/components/ui/horizontal-scroll-boundary";
import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry";
import { isWeb } from "@/constants/platform";
import { usePanelAwareHorizontalScrollGesture } from "@/mobile-panels/gestures";
@@ -38,22 +43,35 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
*/
export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement {
const scrollGesture = usePanelAwareHorizontalScrollGesture();
const scrollBoundary = useHorizontalScrollBoundary();
return (
<View style={styles.bar} pointerEvents="box-none">
<View style={styles.trackViewport} pointerEvents="box-none">
<GestureDetector gesture={scrollGesture}>
<ScrollView
<Animated.ScrollView
testID="composer-track-scroll"
style={styles.trackScroller}
horizontal
keyboardShouldPersistTaps="handled"
showsHorizontalScrollIndicator={false}
pointerEvents="box-none"
onLayout={scrollBoundary.onLayout}
onContentSizeChange={scrollBoundary.onContentSizeChange}
onScroll={scrollBoundary.onScroll}
scrollEventThrottle={16}
>
<View style={styles.track} pointerEvents="box-none">
{children}
</View>
</ScrollView>
</Animated.ScrollView>
</GestureDetector>
<HorizontalScrollBoundaryShades
visible
backdrop="surface"
testIDPrefix="composer-track-scroll-shade"
bottomInset={0}
leftStyle={scrollBoundary.leftShadeStyle}
rightStyle={scrollBoundary.rightShadeStyle}
/>
</View>
</View>
);
@@ -393,6 +411,13 @@ const styles = StyleSheet.create((theme) => {
flexGrow: 0,
flexShrink: 1,
},
// Only as wide as its pills, so the rest of the row passes touches to the transcript. The
// scroller can't use box-none for that: Android's horizontal ScrollView won't scroll with it.
trackScroller: {
alignSelf: "flex-start",
// Web sizes a flex-start scroller to its content, so without a cap it overflows the bar.
maxWidth: "100%",
},
track: {
flexDirection: "row",
alignItems: "center",
@@ -386,8 +386,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
});
};
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
observe: (agentIds) => {
const observation = client.observeTimeline(agentIds);
canCheckFreshness: () =>
client.getLastServerInfoMessage()?.features?.timelineFreshnessCheck === true,
observe: (agentIds, cursors) => {
const observation = client.observeTimeline(agentIds, { cursors });
observation.subscribe({
snapshot: () => {},
update: (message) => {
@@ -455,6 +455,60 @@ describe("deriveAgentScreenViewState", () => {
expect(ready.sync).toEqual({ status: "idle" });
});
it("stays silent over cached content while the freshness check is unanswered", () => {
const input: AgentScreenMachineInput = {
...createBaseInput(),
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
visibilityCatchUpStatus: "checking",
hasHydratedHistoryBefore: true,
};
const checking = expectReadyState(
deriveAgentScreenViewState({ input, memory: createBaseMemory() }).state,
);
const behind = expectReadyState(
deriveAgentScreenViewState({
input: { ...input, visibilityCatchUpStatus: "pending" },
memory: createBaseMemory(),
}).state,
);
expect(checking.sync).toEqual({ status: "catching_up", ui: "silent" });
expect(behind.sync).toEqual({ status: "catching_up", ui: "status" });
});
it("keeps reconnecting until the daemon answers the check made after a reconnect", () => {
const ready = expectReadyState(
deriveAgentScreenViewState({
input: {
...createBaseInput(),
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
isConnected: true,
visibilityCatchUpStatus: "reattaching",
hasHydratedHistoryBefore: true,
},
memory: createBaseMemory(),
}).state,
);
expect(ready.sync).toEqual({ status: "reconnecting" });
});
it("keeps the blocking overlay while a chat with nothing cached is checked", () => {
const input: AgentScreenMachineInput = {
...createBaseInput(),
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
visibilityCatchUpStatus: "checking",
hasHydratedHistoryBefore: false,
};
const ready = expectReadyState(
deriveAgentScreenViewState({ input, memory: createBaseMemory() }).state,
);
expect(ready.sync).toEqual({ status: "catching_up", ui: "overlay" });
});
it("keeps optimistic create non-blocking while timeline and authoritative history catch up", () => {
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
@@ -157,12 +157,13 @@ function resolveAgentScreenSource(args: {
function resolveCatchingUpUi(args: {
hasOptimisticCreateContinuity: boolean;
isFreshnessCheckPending: boolean;
isVisibilityCatchUpPending: boolean;
hasHydratedHistoryBefore: boolean;
hadInitialSyncFailure: boolean;
}): "overlay" | "status" | "silent" {
if (args.hasOptimisticCreateContinuity) return "silent";
if (args.hasHydratedHistoryBefore) return "status";
if (args.hasHydratedHistoryBefore) return args.isFreshnessCheckPending ? "silent" : "status";
if (args.isVisibilityCatchUpPending) return "overlay";
if (args.hadInitialSyncFailure) return "silent";
return "overlay";
@@ -180,7 +181,11 @@ function resolveAgentScreenSync(args: {
) {
return { status: "idle" };
}
if (!input.isConnected && !input.isArchived) {
// A reconnected chat is not back until the daemon answers whether it is current.
if (
(!input.isConnected || input.visibilityCatchUpStatus === "reattaching") &&
!input.isArchived
) {
return { status: "reconnecting" };
}
if (input.missingAgentState.kind === "error") {
@@ -189,16 +194,16 @@ function resolveAgentScreenSync(args: {
if (input.visibilityCatchUpStatus === "error" || input.visibilityCatchUpStatus === "retrying") {
return { status: "sync_error", isRetrying: input.visibilityCatchUpStatus === "retrying" };
}
if (
input.visibilityCatchUpStatus === "pending" ||
input.needsAuthoritativeSync ||
input.isHistorySyncing
) {
const isFreshnessCheckPending = input.visibilityCatchUpStatus === "checking";
const isVisibilityCatchUpPending =
input.visibilityCatchUpStatus === "pending" || isFreshnessCheckPending;
if (isVisibilityCatchUpPending || input.needsAuthoritativeSync || input.isHistorySyncing) {
return {
status: "catching_up",
ui: resolveCatchingUpUi({
hasOptimisticCreateContinuity: hasOptimisticCreateContinuity(input),
isVisibilityCatchUpPending: input.visibilityCatchUpStatus === "pending",
isFreshnessCheckPending,
isVisibilityCatchUpPending,
hasHydratedHistoryBefore: input.hasHydratedHistoryBefore,
hadInitialSyncFailure,
}),
+7 -9
View File
@@ -86,7 +86,7 @@ import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import {
selectAgentTimelineState,
selectAgentTurnPresentation,
selectAgentTimelineTurnPresentation,
type Agent,
useSessionStore,
} from "@/stores/session-store";
@@ -726,13 +726,11 @@ function ChatAgentContent({
(listener: () => void) => viewedTimelineSync?.subscribe(listener) ?? (() => {}),
[viewedTimelineSync],
);
const readTimelineStatus = useCallback(
() =>
!agentId || !viewedTimelineSync
? ("ready" as const)
: viewedTimelineSync.getAgentTimelineStatus(agentId),
[agentId, viewedTimelineSync],
);
const readTimelineStatus = useCallback((): ViewedTimelineStatus => {
if (!agentId) return "ready";
// Until the owner is published nothing has confirmed this chat, so its check has not begun.
return viewedTimelineSync?.getAgentTimelineStatus(agentId) ?? "checking";
}, [agentId, viewedTimelineSync]);
const timelineStatus = useSyncExternalStore(
subscribeToVisibilityCatchUp,
readTimelineStatus,
@@ -1411,7 +1409,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
const turnPresentation = useSessionStore(
useShallow((state) =>
agentId
? selectAgentTurnPresentation(state.sessions[serverId], agentId)
? selectAgentTimelineTurnPresentation(state.sessions[serverId], agentId)
: { isActive: false, isCancelling: false, startedAt: null, turnId: null },
),
);
@@ -4,6 +4,7 @@ import type {
FetchAgentsEntry,
FetchAgentsOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
import {
normalizeProjectDescriptor,
@@ -482,6 +483,26 @@ export class DirectorySync {
return page;
}
/**
* Captures these chats' lifecycle before a subscribe. The returned function keeps the agent rows
* its answer carries, which is what a timeline fetch would have delivered for each of them.
*/
captureSubscribedAgents(
agentIds: string[],
): (agents: Record<string, AgentSnapshotPayload>) => void {
const tokens = new Map(agentIds.map((id) => [id, this.agents.captureTimeline(id)]));
return (agents) => {
let accepted = false;
for (const [agentId, agent] of Object.entries(agents)) {
const token = tokens.get(agentId);
if (token && this.agents.submitTimelineAgent(token, agent)) accepted = true;
}
if (!accepted) return;
this.revision += 1;
this.persistCheckpoint();
};
}
async refreshAgents(
input: RefreshAgentDirectoryInput = {},
): Promise<RefreshAgentDirectoryResult> {
@@ -206,6 +206,7 @@ export class AgentStoreProjection {
const store = useSessionStore.getState();
const removeKey = withoutAgent(agentId);
store.setAgentTimelineCursor(this.serverId, removeKey);
store.setAgentTimelineUnsettledTurn(this.serverId, agentId, null);
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
store.setAgentStreamTail(this.serverId, removeKey);
store.clearAgentStreamHead(this.serverId, agentId);
+14 -1
View File
@@ -2880,7 +2880,20 @@ export class HostRuntimeStore {
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId),
applyTurnLiveness: (agentId, transitions) =>
directory.applyAgentTurnLiveness(agentId, transitions),
ports,
ports: {
...ports,
observe: (agentIds, cursors) => {
const keepAgents = directory.captureSubscribedAgents(agentIds);
const observation = ports.observe(agentIds, cursors);
return {
ready: observation.ready.then((answer) => {
if (answer?.agents) keepAgents(answer.agents);
return answer;
}),
release: () => observation.release(),
};
},
},
});
}
@@ -216,6 +216,19 @@ function timeline(text = "Cached") {
};
}
it("keeps the unsettled turn with the cached messages", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const cached = {
...timeline(),
unsettledTurn: { startedAt: new Date("2026-08-26T09:59:00.000Z"), turnId: "turn-1" },
};
writer.commitTimeline(SERVER_ID, "agent-1", cached);
await writer.flush();
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toEqual(cached);
});
function commitDirectory(
cache: ReplicaCache,
serverId: string,
@@ -20,6 +20,7 @@ import {
type AgentToolCallData,
type StreamItem,
} from "@/types/stream";
import type { UnsettledTurn } from "@/timeline/turn-liveness";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { clearLegacyReplicaCache } from "./legacy-cleanup";
import {
@@ -67,6 +68,8 @@ export interface CachedTimeline {
items: StreamItem[];
range: AgentTimelineCursorState | null;
hasOlder: boolean;
/** Set when these messages were captured while a turn was open and not yet caught up. */
unsettledTurn?: UnsettledTurn;
}
const PERSIST_DELAY_MS = 1_000;
@@ -356,6 +359,9 @@ const StoredTimelineSchema = z.strictObject({
})
.nullable(),
hasOlder: z.boolean(),
unsettledTurn: z
.strictObject({ turnId: z.string().nullable(), startedAt: IsoDateSchema.nullable() })
.optional(),
});
type StoredAgent = z.infer<typeof StoredAgentSchema>;
@@ -412,6 +418,16 @@ function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | nu
items: stored.items.map(deserializeTimelineItem),
range: stored.range,
hasOlder: stored.hasOlder,
...(stored.unsettledTurn
? {
unsettledTurn: {
turnId: stored.unsettledTurn.turnId,
startedAt: stored.unsettledTurn.startedAt
? new Date(stored.unsettledTurn.startedAt)
: null,
},
}
: {}),
};
}
@@ -812,6 +828,14 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null {
? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq }
: null,
hasOlder: canPersistCoverage ? timeline.hasOlder : false,
...(timeline.unsettledTurn
? {
unsettledTurn: {
turnId: timeline.unsettledTurn.turnId,
startedAt: timeline.unsettledTurn.startedAt?.toISOString() ?? null,
},
}
: {}),
};
}
+93
View File
@@ -56,10 +56,12 @@ import {
type WorkspaceAgentActivity,
} from "@/utils/workspace-agent-activity";
import {
resolveTimelineTurnPresentation,
resolveTurnPresentation,
TURN_LIVENESS_IDLE,
type TurnLiveness,
type TurnPresentation,
type UnsettledTurn,
} from "@/timeline/turn-liveness";
export interface AgentRuntimeInfo {
@@ -374,6 +376,64 @@ export function selectAgentTurnPresentation(
);
}
/** Turn state for the chat's own timeline. Every other surface reads the true status above. */
export function selectAgentTimelineTurnPresentation(
session: SessionState | undefined,
agentId: string,
): TurnPresentation {
return resolveTimelineTurnPresentation(
selectAgentTurnPresentation(session, agentId),
session?.agentTimelineUnsettledTurn.get(agentId),
);
}
/** The unsettled turn a chat's messages carry: the one open right now, or the one it holds. */
export function selectAgentUnsettledTurn(
session: SessionState | undefined,
agentId: string,
): UnsettledTurn | undefined {
const turn = session?.agents.get(agentId)?.turn ?? session?.agentDetails.get(agentId)?.turn;
if (turn?.phase === "open") return { turnId: turn.turnId, startedAt: turn.startedAt };
return session?.agentTimelineUnsettledTurn.get(agentId);
}
function withUnsettledTurn(
current: Map<string, UnsettledTurn>,
agentId: string,
turn: UnsettledTurn,
): Map<string, UnsettledTurn> {
const held = current.get(agentId);
// COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6.
// Hosts without turn ids report null for every turn, so only the start time tells two turns apart.
if (held?.turnId === turn.turnId && held?.startedAt?.getTime() === turn.startedAt?.getTime()) {
return current;
}
const next = new Map(current);
next.set(agentId, turn);
return next;
}
function withoutUnsettledTurn(
current: Map<string, UnsettledTurn>,
agentId: string,
): Map<string, UnsettledTurn> {
if (!current.has(agentId)) return current;
const next = new Map(current);
next.delete(agentId);
return next;
}
// The messages that bring a chat current settle its turn in this same commit.
function commitUnsettledTurn(
current: Map<string, UnsettledTurn>,
agentId: string,
commit: { synchronized: boolean; unsettledTurn?: UnsettledTurn },
): Map<string, UnsettledTurn> {
if (commit.synchronized) return withoutUnsettledTurn(current, agentId);
if (commit.unsettledTurn) return withUnsettledTurn(current, agentId, commit.unsettledTurn);
return current;
}
function latestTasksFromStream(items: readonly StreamItem[]): TodoEntry[] {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
@@ -426,6 +486,7 @@ export interface SessionState {
agentTasks: Map<string, TodoEntry[]>;
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
agentTimelineUnsettledTurn: Map<string, UnsettledTurn>;
agentTimelineHasOlder: Map<string, boolean>;
agentTimelineHasNewer: Map<string, boolean>;
agentTimelineOlderFetchInFlight: Map<string, boolean>;
@@ -569,8 +630,14 @@ interface SessionStoreActions {
newer: boolean;
synchronized: boolean;
acknowledgedClientMessageIds: string[];
unsettledTurn?: UnsettledTurn;
},
) => void;
setAgentTimelineUnsettledTurn: (
serverId: string,
agentId: string,
turn: UnsettledTurn | null,
) => void;
// Initializing agents
setInitializingAgents: (
@@ -674,6 +741,7 @@ function createInitialSessionState(
agentTasks: new Map(),
messageSubmissions: new Map(),
agentTimelineCursor: new Map(),
agentTimelineUnsettledTurn: new Map(),
agentTimelineHasOlder: new Map(),
agentTimelineHasNewer: new Map(),
agentTimelineOlderFetchInFlight: new Map(),
@@ -1455,6 +1523,11 @@ export const useSessionStore = create<SessionStore>()(
nextAuthoritative.set(agentId, true);
nextSyncGeneration.set(agentId, session.historySyncGeneration);
}
const agentTimelineUnsettledTurn = commitUnsettledTurn(
session.agentTimelineUnsettledTurn,
agentId,
state,
);
const tasks = latestTasksFromStream([...state.items, ...state.head]);
const agentTasks = new Map(session.agentTasks);
if (tasks.length > 0) agentTasks.set(agentId, tasks);
@@ -1472,6 +1545,7 @@ export const useSessionStore = create<SessionStore>()(
agentTimelineCursor: nextCursor,
agentTimelineHasOlder: nextHasOlder,
agentTimelineHasNewer: nextHasNewer,
agentTimelineUnsettledTurn,
agentAuthoritativeHistoryApplied: nextAuthoritative,
agentHistorySyncGeneration: nextSyncGeneration,
messageSubmissions,
@@ -1481,6 +1555,25 @@ export const useSessionStore = create<SessionStore>()(
});
},
setAgentTimelineUnsettledTurn: (serverId, agentId, turn) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
const current = session.agentTimelineUnsettledTurn;
const next = turn
? withUnsettledTurn(current, agentId, turn)
: withoutUnsettledTurn(current, agentId);
if (next === current) return prev;
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentTimelineUnsettledTurn: next },
},
};
});
},
// Initializing agents
setInitializingAgents: (serverId, state) => {
set((prev) => {
@@ -165,6 +165,8 @@ it("feeds restored layout to timeline sync as a release signal, not a subscripti
const sync = createViewedTimelineSync({
replaceDemandedAgentIds: () => undefined,
prepare: async () => undefined,
canCheckFreshness: () => false,
applyCurrentTimeline: () => undefined,
observe: (agentIds) => {
observed.push(agentIds);
return { ready: Promise.resolve(), release: async () => undefined };
@@ -174,6 +176,8 @@ it("feeds restored layout to timeline sync as a release signal, not a subscripti
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
onCatchUpEnded: () => undefined,
reportError: () => undefined,
onCatchUpOwed: () => undefined,
onCatchUpSettled: () => undefined,
});
// The edge session-context.tsx owns. Pointing it at subscription instead would resume every
// agent behind a restored tab on the daemon.
+645 -5
View File
@@ -8,15 +8,23 @@ import {
import { afterEach, describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { CachedTimeline } from "@/runtime/replica-cache";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import {
selectAgentTimelineState,
selectAgentTimelineTurnPresentation,
selectAgentTurnPresentation,
useSessionStore,
type Agent,
} from "@/stores/session-store";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import type { StreamItem } from "@/types/stream";
import type { TurnLivenessTransition } from "./turn-liveness";
import { reduceTurnLiveness, type TurnLivenessTransition } from "./turn-liveness";
import {
createTimelineReplica,
createViewedTimelineOwner,
type TimelineReplicaStorage,
type TimelineResponsePayload,
type ViewedTimelineOwner,
type ViewedTimelineOwnerPorts,
} from "./viewed-timeline-sync";
const SERVER_ID = "timeline-replica-host";
@@ -47,6 +55,15 @@ function createOwner(
agentId: string,
transitions: readonly TurnLivenessTransition[],
) => void = () => undefined,
fetchPage: ViewedTimelineOwnerPorts["fetchPage"] = async () => ({
hasNewer: false,
endCursor: null,
}),
observe: ViewedTimelineOwnerPorts["observe"] = () => ({
ready: Promise.resolve(),
release: async () => undefined,
}),
checksFreshness = false,
): ViewedTimelineOwner {
const replica = createTimelineReplica({
serverId: SERVER_ID,
@@ -60,9 +77,17 @@ function createOwner(
drainQueuedAgentMessage: () => undefined,
applyTurnLiveness,
ports: {
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
readCursor: () => undefined,
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
canCheckFreshness: () => checksFreshness,
observe,
readCursor: () => {
if (!checksFreshness) return undefined;
const session = useSessionStore.getState().sessions[SERVER_ID];
const timeline = selectAgentTimelineState(session, AGENT_ID);
return timeline.status === "synced" && timeline.range
? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq }
: undefined;
},
fetchPage,
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
reportError: () => undefined,
},
@@ -626,6 +651,620 @@ describe("turn boundaries", () => {
});
});
describe("the turn footer changes together with its messages", () => {
const OPEN_TURN = { turnId: "turn-1", startedAt: new Date("2026-08-26T09:59:00.000Z") };
function setTurn(transition: TurnLivenessTransition): void {
useSessionStore.getState().setAgents(SERVER_ID, (agents) => {
const agent =
agents.get(AGENT_ID) ??
({
id: AGENT_ID,
turn: { phase: "idle", cancellationRequestId: null },
} as Agent);
const next = new Map(agents);
next.set(AGENT_ID, { ...agent, turn: reduceTurnLiveness(agent.turn, transition) });
return next;
});
}
const agentIsRunning = () => setTurn({ type: "snapshot", activeTurn: OPEN_TURN });
const idleSnapshotArrives = () => setTurn({ type: "snapshot", activeTurn: null });
type Footer = "running" | "settled";
function footer(): Footer {
const session = useSessionStore.getState().sessions[SERVER_ID];
const turn = selectAgentTimelineTurnPresentation(session, AGENT_ID);
return turn.isActive ? "running" : "settled";
}
function timerStart(): Date | null {
const session = useSessionStore.getState().sessions[SERVER_ID];
return selectAgentTimelineTurnPresentation(session, AGENT_ID).startedAt;
}
function replies(): string[] {
const session = useSessionStore.getState().sessions[SERVER_ID];
return [
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
]
.filter((entry) => entry.kind === "assistant_message")
.map((entry) => entry.text);
}
// A chat that was current when the connection dropped, holding `cachedReply` if given.
function createChat(
cachedReply: string | null,
fetchPage?: ViewedTimelineOwnerPorts["fetchPage"],
): ViewedTimelineOwner {
useSessionStore.getState().initializeSession(SERVER_ID, null);
useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, {
items: cachedReply ? [item("reply-1", cachedReply, 4)] : [],
head: [],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
older: "none",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
return createOwner(
{ readTimeline: async () => undefined, commitTimeline: () => undefined },
undefined,
fetchPage,
);
}
async function connectAndCatchUp(owner: ViewedTimelineOwner): Promise<void> {
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
owner.setConnected(true);
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready");
}
function catchUpPage(input: {
replyThroughSeq: number | null;
reply?: string;
}): TimelineResponsePayload {
const end = input.replyThroughSeq ?? 4;
return {
requestId: "catch-up",
agentId: AGENT_ID,
agent: null,
direction: "after",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 1, maxSeq: end, nextSeq: end + 1 },
startCursor: input.reply ? { epoch: "epoch-1", seq: 5 } : null,
endCursor: input.reply ? { epoch: "epoch-1", seq: end } : null,
entries: input.reply
? [
{
provider: "mock",
item: { type: "assistant_message", text: input.reply },
timestamp: "2026-08-26T10:00:02.000Z",
seqStart: 5,
seqEnd: end,
sourceSeqRanges: [{ startSeq: 5, endSeq: end }],
collapsed: [],
},
]
: [],
error: null,
hasNewer: false,
hasOlder: false,
staleCursor: false,
gap: false,
};
}
function recordCommits(): { commits: { footer: Footer; replies: string[] }[]; stop(): void } {
const commits: { footer: Footer; replies: string[] }[] = [
{ footer: footer(), replies: replies() },
];
const stop = useSessionStore.subscribe(() => {
commits.push({ footer: footer(), replies: replies() });
});
return { commits, stop };
}
it.each([
["only the prompt cached", null, ["Done."]],
["a partial reply cached", "Half", ["HalfDone."]],
])("snapshot before messages, %s: running until messages land", async (_name, cached, final) => {
const owner = createChat(cached);
agentIsRunning();
await connectAndCatchUp(owner);
expect(footer()).toBe("running");
owner.setConnected(false);
expect(footer()).toBe("running");
owner.setConnected(true);
const recorder = recordCommits();
idleSnapshotArrives();
expect(footer()).toBe("running");
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
recorder.stop();
expect(footer()).toBe("settled");
expect(replies()).toEqual(final);
// The messages and the settled footer arrive in one commit, with no state between.
const previous = recorder.commits.at(-2);
expect(previous?.footer).toBe("running");
expect(previous?.replies).not.toEqual(final);
expect(
recorder.commits.filter(
(commit) => commit.footer === "settled" && commit.replies.join() !== final.join(),
),
).toEqual([]);
owner.dispose();
});
it("messages before snapshot: running stays until the idle snapshot, then completes", async () => {
const owner = createChat("Half");
agentIsRunning();
await connectAndCatchUp(owner);
owner.setConnected(false);
owner.setConnected(true);
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
expect(replies()).toEqual(["HalfDone."]);
expect(footer()).toBe("running");
idleSnapshotArrives();
expect(footer()).toBe("settled");
owner.dispose();
});
it("a turn that ended with no new messages settles when the empty catch-up lands", async () => {
const owner = createChat("Done.");
agentIsRunning();
await connectAndCatchUp(owner);
owner.setConnected(false);
owner.setConnected(true);
idleSnapshotArrives();
expect(footer()).toBe("running");
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: null }));
expect(footer()).toBe("settled");
expect(replies()).toEqual(["Done."]);
owner.dispose();
});
it("keeps the true idle status for every surface outside the timeline", async () => {
const owner = createChat("Half");
agentIsRunning();
await connectAndCatchUp(owner);
owner.setConnected(false);
owner.setConnected(true);
idleSnapshotArrives();
expect(footer()).toBe("running");
const session = useSessionStore.getState().sessions[SERVER_ID];
expect(selectAgentTurnPresentation(session, AGENT_ID).isActive).toBe(false);
owner.dispose();
});
it("keeps the running turn's identity and timer start when the idle status arrives", async () => {
const owner = createChat("Half");
agentIsRunning();
await connectAndCatchUp(owner);
owner.setConnected(false);
owner.setConnected(true);
idleSnapshotArrives();
const session = useSessionStore.getState().sessions[SERVER_ID];
expect(selectAgentTimelineTurnPresentation(session, AGENT_ID)).toEqual({
isActive: true,
isCancelling: false,
startedAt: OPEN_TURN.startedAt,
turnId: "turn-1",
});
owner.dispose();
});
it("an agent still running on return keeps its running footer", async () => {
const owner = createChat("Half");
agentIsRunning();
await connectAndCatchUp(owner);
owner.setConnected(false);
owner.setConnected(true);
agentIsRunning();
expect(footer()).toBe("running");
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 6, reply: " more" }));
expect(footer()).toBe("running");
owner.dispose();
});
it("applies a snapshot at once when the chat is current", async () => {
const owner = createChat("Done.");
agentIsRunning();
await connectAndCatchUp(owner);
idleSnapshotArrives();
expect(footer()).toBe("settled");
owner.dispose();
});
it("a reconnect that finds nothing changed never leaves the settled footer", async () => {
const owner = createChat("Done.");
await connectAndCatchUp(owner);
const recorder = recordCommits();
owner.setConnected(false);
owner.setConnected(true);
idleSnapshotArrives();
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: null }));
recorder.stop();
expect(recorder.commits.map((commit) => commit.footer)).not.toContain("running");
owner.dispose();
});
type Answer = "current" | "behind";
// A chat whose subscribes wait for the test to answer them, and whose fetches are recorded.
function createCheckedChat(cachedReply: string | null) {
useSessionStore.getState().initializeSession(SERVER_ID, null);
useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, {
items: cachedReply ? [item("reply-1", cachedReply, 4)] : [],
head: [],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
older: "none",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
const unanswered: ((answer: Answer, beforeAnswer?: () => void) => void)[] = [];
let fetches = 0;
let finishFetch = () => {};
const owner = createOwner(
{ readTimeline: async () => undefined, commitTimeline: () => undefined },
undefined,
() => {
fetches += 1;
return new Promise((resolve) => {
finishFetch = () => resolve({ hasNewer: false, endCursor: null });
});
},
() => ({
// The host runtime keeps the answer's agent rows in this same continuation.
ready: new Promise((resolve) => {
unanswered.push((answer, beforeAnswer) => {
beforeAnswer?.();
resolve({ timelines: { [AGENT_ID]: answer } });
});
}),
release: async () => undefined,
}),
true,
);
const answer = async (value: Answer, beforeAnswer?: () => void) => {
await expect.poll(() => unanswered.length).toBeGreaterThan(0);
unanswered.shift()?.(value, beforeAnswer);
};
const open = async () => {
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
owner.setConnected(true);
await answer("current");
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready");
};
return { owner, answer, open, fetchCount: () => fetches, finishFetch: () => finishFetch() };
}
function leaveAndReturn(owner: ViewedTimelineOwner, way: "reconnect" | "foreground"): void {
if (way === "reconnect") {
owner.setConnected(false);
owner.setConnected(true);
return;
}
owner.setActive(false);
owner.setActive(true);
}
it.each([
["reconnect", "reattaching"],
["foreground", "checking"],
] as const)(
"freshness check on %s: the held turn stays through the check and current settles it",
async (way, status) => {
const chat = createCheckedChat("Done.");
agentIsRunning();
await chat.open();
leaveAndReturn(chat.owner, way);
idleSnapshotArrives();
expect(chat.owner.getAgentTimelineStatus(AGENT_ID)).toBe(status);
expect(footer()).toBe("running");
const recorder = recordCommits();
await chat.answer("current");
await expect.poll(() => chat.owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready");
recorder.stop();
expect(footer()).toBe("settled");
expect(replies()).toEqual(["Done."]);
expect(chat.fetchCount()).toBe(0);
expect(recorder.commits.map((commit) => commit.footer).slice(0, 2)).toEqual([
"running",
"settled",
]);
chat.owner.dispose();
},
);
it("freshness check: an idle row carried by the current answer settles the footer in the same step", async () => {
const chat = createCheckedChat("Done.");
agentIsRunning();
await chat.open();
leaveAndReturn(chat.owner, "reconnect");
let footerAfterRow: Footer | null = null;
await chat.answer("current", () => {
idleSnapshotArrives();
footerAfterRow = footer();
});
await expect.poll(() => chat.owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready");
expect(footerAfterRow).toBe("running");
expect(footer()).toBe("settled");
chat.owner.dispose();
});
it("freshness check: an agent still running keeps its footer after current", async () => {
const chat = createCheckedChat("Half");
agentIsRunning();
await chat.open();
leaveAndReturn(chat.owner, "reconnect");
await chat.answer("current");
await expect.poll(() => chat.owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready");
expect(footer()).toBe("running");
idleSnapshotArrives();
expect(footer()).toBe("settled");
chat.owner.dispose();
});
it("freshness check: behind holds the turn until the fetched rows land with the completed footer", async () => {
const chat = createCheckedChat("Half");
agentIsRunning();
await chat.open();
leaveAndReturn(chat.owner, "reconnect");
idleSnapshotArrives();
await chat.answer("behind");
await expect.poll(() => chat.fetchCount()).toBe(1);
expect(footer()).toBe("running");
const recorder = recordCommits();
chat.owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
recorder.stop();
expect(footer()).toBe("settled");
expect(replies()).toEqual(["HalfDone."]);
expect(recorder.commits.at(-2)).toMatchObject({ footer: "running", replies: ["Half"] });
expect(recorder.commits.at(-1)).toMatchObject({ footer: "settled", replies: ["HalfDone."] });
chat.finishFetch();
await expect.poll(() => chat.owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready");
chat.owner.dispose();
});
it("catch-up failure gives the footer the true status", async () => {
let failNext = false;
const owner = createChat("Half", async () => {
if (failNext) throw new Error("catch-up failed");
return { hasNewer: false, endCursor: null };
});
agentIsRunning();
await connectAndCatchUp(owner);
failNext = true;
owner.setConnected(false);
owner.setConnected(true);
idleSnapshotArrives();
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("error");
expect(footer()).toBe("settled");
owner.dispose();
});
it("a catch-up that completes without a page still releases the footer", async () => {
const owner = createChat("Half");
agentIsRunning();
await connectAndCatchUp(owner);
owner.setConnected(false);
idleSnapshotArrives();
expect(footer()).toBe("running");
owner.setConnected(true);
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready");
expect(footer()).toBe("settled");
owner.dispose();
});
it("a chat closed while running and reopened after it finished keeps the running footer", async () => {
const owner = createChat("Half");
agentIsRunning();
await connectAndCatchUp(owner);
owner.replaceVisibleAgentIds("test", []);
owner.replaceOpenTabAgentIds([]);
idleSnapshotArrives();
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
expect(footer()).toBe("running");
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
expect(footer()).toBe("settled");
owner.dispose();
});
it.each([
{ host: "that reports turn ids", olderTurnId: "turn-1", newerTurnId: "turn-2" },
{ host: "without turn ids", olderTurnId: null, newerTurnId: null },
])("on a host $host, a newer turn replaces the held one so the timer follows it", async (ids) => {
const newerTurn = { turnId: ids.newerTurnId, startedAt: new Date("2026-08-26T09:59:30.000Z") };
const owner = createChat("Half");
setTurn({
type: "snapshot",
activeTurn: { turnId: ids.olderTurnId, startedAt: OPEN_TURN.startedAt },
});
await connectAndCatchUp(owner);
owner.replaceVisibleAgentIds("test", []);
owner.replaceOpenTabAgentIds([]);
idleSnapshotArrives();
setTurn({ type: "snapshot", activeTurn: newerTurn });
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
idleSnapshotArrives();
expect(footer()).toBe("running");
expect(timerStart()).toEqual(newerTurn.startedAt);
owner.dispose();
});
function paintChat(unsettledTurn: CachedTimeline["unsettledTurn"]): ViewedTimelineOwner {
useSessionStore.getState().initializeSession(SERVER_ID, null);
idleSnapshotArrives();
return createOwner({
readTimeline: async () => ({
agentId: AGENT_ID,
items: [item("reply-1", "Half", 4)],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
hasOlder: false,
...(unsettledTurn ? { unsettledTurn } : {}),
}),
commitTimeline: () => undefined,
});
}
it("first paint from cache: shows the running footer in the first frame when the cached messages were from an open turn", async () => {
const owner = paintChat({ turnId: "turn-1", startedAt: OPEN_TURN.startedAt });
const recorder = recordCommits();
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect.poll(() => replies()).toEqual(["Half"]);
recorder.stop();
expect(recorder.commits.find((commit) => commit.replies.length > 0)?.footer).toBe("running");
expect(timerStart()).toEqual(OPEN_TURN.startedAt);
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
expect(footer()).toBe("settled");
owner.dispose();
});
async function subscriptionRefusedDuringCacheRead(): Promise<{
owner: ViewedTimelineOwner;
finishCacheRead(): void;
}> {
useSessionStore.getState().initializeSession(SERVER_ID, null);
idleSnapshotArrives();
let resolveRead: (timeline: CachedTimeline) => void = () => undefined;
const owner = createOwner(
{
readTimeline: () => new Promise((resolve) => (resolveRead = resolve)),
commitTimeline: () => undefined,
},
undefined,
undefined,
() => ({
ready: Promise.reject(new Error("subscription refused")),
release: async () => undefined,
}),
);
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
owner.setConnected(true);
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("error");
return {
owner,
finishCacheRead: () =>
resolveRead({
agentId: AGENT_ID,
items: [item("reply-1", "Half", 4)],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
hasOlder: false,
unsettledTurn: { turnId: "turn-1", startedAt: OPEN_TURN.startedAt },
}),
};
}
it("first paint from cache: a catch-up that failed during the cache read keeps the true status", async () => {
const { owner, finishCacheRead } = await subscriptionRefusedDuringCacheRead();
finishCacheRead();
await expect.poll(() => replies()).toEqual(["Half"]);
expect(owner.getAgentTimelineStatus(AGENT_ID)).toBe("error");
expect(footer()).toBe("settled");
owner.dispose();
});
it("first paint from cache: a catch-up owed again during the cache read keeps the running footer", async () => {
const { owner, finishCacheRead } = await subscriptionRefusedDuringCacheRead();
owner.setConnected(false);
finishCacheRead();
await expect.poll(() => replies()).toEqual(["Half"]);
expect(footer()).toBe("running");
owner.dispose();
});
it("first paint from cache: shows the settled footer at once when the cached messages were from a finished turn", async () => {
const owner = paintChat(undefined);
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect.poll(() => replies()).toEqual(["Half"]);
expect(footer()).toBe("settled");
owner.dispose();
});
it("first paint from cache: records the open turn with the messages and drops it once the turn ends", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
applySynced(AGENT_ID, 8);
agentIsRunning();
const commits: CachedTimeline[] = [];
const owner = createOwner(
{
readTimeline: async () => undefined,
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
},
(_agentId, transitions) => {
for (const transition of transitions) setTurn(transition);
},
);
owner.applyStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "Working" },
} as AgentStreamEventPayload,
seq: 9,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:01.000Z"),
});
owner.flushStreamAgent(AGENT_ID);
expect(commits.at(-1)?.unsettledTurn).toEqual({
turnId: "turn-1",
startedAt: expect.any(Date),
});
owner.applyStreamEvent(AGENT_ID, {
event: { type: "turn_completed", provider: "codex", turnId: "turn-1" },
seq: undefined,
epoch: undefined,
timestamp: new Date("2026-08-26T10:00:02.000Z"),
});
expect(commits.at(-1)?.unsettledTurn).toBeUndefined();
owner.dispose();
});
});
describe("create handoff lifetime", () => {
const DRAFT_ID = "draft-1";
@@ -691,6 +1330,7 @@ describe("create handoff lifetime", () => {
drainQueuedAgentMessage: () => undefined,
applyTurnLiveness: () => undefined,
ports: {
canCheckFreshness: () => false,
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
readCursor: () => undefined,
fetchPage: async () => {
@@ -54,6 +54,16 @@ export interface TurnPresentation {
turnId: string | null;
}
/**
* The turn a chat's messages were captured during, kept until the chat has caught up past it.
* While it is held and the agent reads idle, the messages on screen may be older than the turn's
* end, so the chat cannot claim the turn is finished yet.
*/
export interface UnsettledTurn {
turnId: string | null;
startedAt: Date | null;
}
export function resolveTurnPresentation(
liveness: TurnLiveness,
hasActiveSubmission: boolean,
@@ -74,6 +84,24 @@ export function resolveTurnPresentation(
};
}
/**
* What a chat shows for its turn. It differs from the true status only while the agent has
* finished and the chat's messages have not caught up: the chat keeps showing the running turn, so
* the completed footer can land with the messages it belongs to.
*/
export function resolveTimelineTurnPresentation(
turn: TurnPresentation,
unsettledTurn: UnsettledTurn | undefined,
): TurnPresentation {
if (turn.isActive || !unsettledTurn) return turn;
return {
isActive: true,
isCancelling: false,
startedAt: unsettledTurn.startedAt,
turnId: unsettledTurn.turnId,
};
}
function closeTurn(current: TurnLiveness, turnId: string | null): TurnLiveness {
// COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6.
const targetsDifferentIdentifiedTurn =
@@ -5,6 +5,7 @@ import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
import {
consumeForcedTimelineTailReplacement,
createViewedTimelineSync,
FRESHNESS_CHECK_QUIET_MS,
type TimelineResponsePayload,
type ViewedTimelineStatus,
type ViewedTimelineSyncPorts,
@@ -28,7 +29,8 @@ function deferred<T>(): Deferred<T> {
interface MembershipRequest {
agentIds: string[];
succeed(): void;
cursors: Record<string, { epoch: string; seq: number }> | undefined;
succeed(timelines?: Record<string, "current" | "behind">): void;
fail(message: string): void;
}
@@ -126,8 +128,12 @@ test("switching chats promotes the newly visible catch-up while another visible
});
class TimelineWorld {
constructor(private readonly observeMembership?: ViewedTimelineSyncPorts["observe"]) {}
constructor(
private readonly observeMembership?: ViewedTimelineSyncPorts["observe"],
private readonly checksFreshness = false,
) {}
readonly errors: string[] = [];
readonly appliedCurrent: string[] = [];
readonly releasedMemberships: string[][] = [];
readonly cursors = new Map<string, { epoch: string; endSeq: number }>();
readonly cacheRequests: string[] = [];
@@ -142,17 +148,24 @@ class TimelineWorld {
status: this.sync.getAgentTimelineStatus(agentId),
});
},
onCatchUpOwed: () => undefined,
onCatchUpSettled: () => undefined,
prepare: async (agentId) => {
this.cacheRequests.push(agentId);
this.cacheRequestWaiters.shift()?.(agentId);
await this.cacheGate?.promise;
},
observe: (agentIds) => {
if (this.observeMembership) return this.observeMembership(agentIds);
const result = deferred<void>();
canCheckFreshness: () => this.checksFreshness,
applyCurrentTimeline: (agentId) => {
this.appliedCurrent.push(agentId);
},
observe: (agentIds, cursors) => {
if (this.observeMembership) return this.observeMembership(agentIds, cursors);
const result = deferred<{ timelines?: Record<string, "current" | "behind"> }>();
this.memberships.push({
agentIds,
succeed: () => result.resolve(),
cursors,
succeed: (timelines) => result.resolve({ timelines }),
fail: (message) => result.reject(new Error(message)),
});
this.releaseMembershipWaiter();
@@ -160,6 +173,7 @@ class TimelineWorld {
ready: result.promise,
release: async () => {
this.releasedMemberships.push(agentIds);
result.reject(new Error("Subscription released"));
},
};
},
@@ -1198,3 +1212,325 @@ test("disposing a view releases its pending observation before bootstrap complet
await Promise.resolve();
world.expectNoPendingFetch();
});
function freshnessWorld(): TimelineWorld {
return new TimelineWorld(undefined, true);
}
test("a chat the daemon reports current finishes catching up without a fetch", async () => {
const world = freshnessWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
world.sync.setConnected(true);
world.show("pane", ["agent-a"]);
const membership = await world.nextMembership();
expect(membership.cursors).toEqual({ "agent-a": { epoch: "epoch-a", seq: 42 } });
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("checking");
membership.succeed({ "agent-a": "current" });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
expect(world.appliedCurrent).toEqual(["agent-a"]);
expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "ready" }]);
world.expectNoPendingFetch();
world.sync.dispose();
});
test("a chat the daemon reports behind fetches from its cursor and reports pending", async () => {
const world = freshnessWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
world.show("pane", ["agent-a"]);
world.sync.setConnected(true);
(await world.nextMembership()).succeed({ "agent-a": "behind" });
const fetch = await world.nextFetch("agent-a");
expect(fetch.request).toMatchObject({
direction: "after",
cursor: { epoch: "epoch-a", seq: 42 },
});
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
expect(world.appliedCurrent).toEqual([]);
fetch.respond({ hasNewer: false });
world.sync.dispose();
});
test("a chat with no cached cursor is not checked and fetches its tail", async () => {
const world = freshnessWorld();
world.show("pane", ["agent-a"]);
world.sync.setConnected(true);
const membership = await world.nextMembership();
expect(membership.cursors).toEqual({});
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
membership.succeed();
expect((await world.nextFetch("agent-a")).request.direction).toBe("tail");
world.sync.dispose();
});
test("an answer without an entry for a chat falls back to the fetch", async () => {
const world = freshnessWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
world.show("pane", ["agent-a"]);
world.sync.setConnected(true);
(await world.nextMembership()).succeed({});
expect((await world.nextFetch("agent-a")).request.direction).toBe("after");
world.sync.dispose();
});
test("a chat being opened waits for its cache before the daemon compares its cursor", async () => {
const world = freshnessWorld();
world.cacheGate = deferred<void>();
world.sync.setConnected(true);
world.show("pane", ["agent-a"]);
await world.nextCacheRequest();
world.expectNoPendingMembership();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("checking");
world.cursors.set("agent-a", { epoch: "cached", endSeq: 9 });
world.cacheGate.resolve();
const membership = await world.nextMembership();
expect(membership.cursors).toEqual({ "agent-a": { epoch: "cached", seq: 9 } });
membership.succeed({ "agent-a": "current" });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.expectNoPendingFetch();
world.sync.dispose();
});
test("an unanswered check starts reporting pending after the delay and resolves on the answer", async () => {
vi.useFakeTimers();
try {
const world = freshnessWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
world.sync.setConnected(true);
world.show("pane", ["agent-a"]);
const membership = await world.nextMembership();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("checking");
await vi.advanceTimersByTimeAsync(FRESHNESS_CHECK_QUIET_MS - 1);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("checking");
await vi.advanceTimersByTimeAsync(1);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
world.expectNoPendingFetch();
membership.succeed({ "agent-a": "current" });
await vi.advanceTimersByTimeAsync(0);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
world.expectNoPendingFetch();
world.sync.dispose();
} finally {
vi.useRealTimers();
}
});
test("a slow cache read does not use up the delay for the daemon's answer", async () => {
vi.useFakeTimers();
try {
const world = freshnessWorld();
world.cacheGate = deferred<void>();
world.sync.setConnected(true);
world.show("pane", ["agent-a"]);
await world.nextCacheRequest();
await vi.advanceTimersByTimeAsync(FRESHNESS_CHECK_QUIET_MS * 4);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("checking");
world.cursors.set("agent-a", { epoch: "cached", endSeq: 9 });
world.cacheGate.resolve();
const membership = await world.nextMembership();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("checking");
await vi.advanceTimersByTimeAsync(FRESHNESS_CHECK_QUIET_MS);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
membership.succeed({ "agent-a": "current" });
await vi.advanceTimersByTimeAsync(0);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
world.sync.dispose();
} finally {
vi.useRealTimers();
}
});
test("foregrounding a current chat checks over the subscription instead of fetching", async () => {
const world = freshnessWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
world.show("pane", ["agent-a"]);
world.sync.setConnected(true);
const first = await world.nextMembership();
first.succeed({ "agent-a": "current" });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.setActive(false);
world.sync.setActive(true);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("checking");
const check = await world.nextMembership();
expect(check.agentIds).toEqual(["agent-a"]);
expect(check.cursors).toEqual({ "agent-a": { epoch: "epoch-a", seq: 42 } });
check.succeed({ "agent-a": "current" });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.expectNoPendingFetch();
world.sync.dispose();
});
test("the old subscription keeps delivering until the daemon accepts its replacement", async () => {
const world = freshnessWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 1 });
world.cursors.set("agent-b", { epoch: "epoch-b", endSeq: 2 });
world.openedEarlier(["agent-b"]);
world.show("pane", ["agent-a"]);
world.sync.setConnected(true);
(await world.nextMembership()).succeed({ "agent-a": "current", "agent-b": "current" });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
world.sync.setActive(false);
world.sync.setActive(true);
const check = await world.nextMembership();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(world.releasedMemberships).toEqual([]);
check.succeed({ "agent-a": "current" });
await vi.waitFor(() => expect(world.releasedMemberships).toEqual([["agent-a", "agent-b"]]));
world.sync.dispose();
});
test("losing the connection during a check releases both the check and what it replaced", async () => {
const world = freshnessWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 1 });
world.show("pane", ["agent-a"]);
world.sync.setConnected(true);
(await world.nextMembership()).succeed({ "agent-a": "current" });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.setActive(false);
world.sync.setActive(true);
await world.nextMembership();
world.sync.setConnected(false);
await vi.waitFor(() => expect(world.releasedMemberships).toEqual([["agent-a"], ["agent-a"]]));
world.sync.dispose();
});
test("a replacement the daemon refuses still releases the subscription it replaced", async () => {
const world = freshnessWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 1 });
world.show("pane", ["agent-a"]);
world.sync.setConnected(true);
(await world.nextMembership()).succeed({ "agent-a": "current" });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.setActive(false);
world.sync.setActive(true);
const check = await world.nextMembership();
check.fail("refused");
await vi.waitFor(() => expect(world.releasedMemberships).toEqual([["agent-a"]]));
world.sync.dispose();
});
test("foregrounding asks only about visible chats; hidden chats stay caught up", async () => {
const world = freshnessWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 1 });
world.cursors.set("agent-b", { epoch: "epoch-b", endSeq: 2 });
world.openedEarlier(["agent-b"]);
world.show("pane", ["agent-a"]);
world.sync.setConnected(true);
(await world.nextMembership()).succeed({ "agent-a": "current", "agent-b": "current" });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
world.sync.setActive(false);
world.sync.setActive(true);
const check = await world.nextMembership();
expect(check.agentIds).toEqual(["agent-a", "agent-b"]);
expect(check.cursors).toEqual({ "agent-a": { epoch: "epoch-a", seq: 1 } });
expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready");
check.succeed({ "agent-a": "current" });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.expectNoPendingFetch();
world.sync.dispose();
});
test("a gap found while the check is outstanding wins over a current answer", async () => {
const world = freshnessWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
world.show("pane", ["agent-a"]);
world.sync.setConnected(true);
const membership = await world.nextMembership();
world.sync.recoverGap("agent-a", { epoch: "epoch-a", endSeq: 42 });
world.expectNoPendingFetch();
membership.succeed({ "agent-a": "current" });
const fetch = await world.nextFetch("agent-a");
expect(fetch.request).toMatchObject({ direction: "after", cursor: { seq: 42 } });
expect(world.appliedCurrent).toEqual([]);
fetch.respond({ hasNewer: false });
world.sync.dispose();
});
test("a failed check reports the error and retries through membership", async () => {
const world = freshnessWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
world.show("pane", ["agent-a"]);
world.sync.setConnected(true);
(await world.nextMembership()).fail("subscribe failed");
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"));
world.sync.retryVisibleAgentTimeline("agent-a");
const retry = await world.nextMembership();
expect(retry.cursors).toEqual({ "agent-a": { epoch: "epoch-a", seq: 42 } });
retry.succeed({ "agent-a": "current" });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.dispose();
});
test("reconnecting reports reattaching instead of pending until the check answers", async () => {
const world = freshnessWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
world.show("pane", ["agent-a"]);
world.sync.setConnected(true);
(await world.nextMembership()).succeed({ "agent-a": "current" });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.setConnected(false);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("reattaching");
world.sync.setConnected(true);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("reattaching");
(await world.nextMembership()).succeed({ "agent-a": "current" });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.expectNoPendingFetch();
world.sync.dispose();
});
test("without the daemon capability nothing changes: no cursors, no checking, fetch on foreground", async () => {
const world = new TimelineWorld();
world.cursors.set("agent-a", { epoch: "epoch-a", endSeq: 42 });
world.show("pane", ["agent-a"]);
world.sync.setConnected(true);
const membership = await world.nextMembership();
expect(membership.cursors).toBeUndefined();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
membership.succeed();
const first = await world.nextFetch("agent-a");
expect(first.request.direction).toBe("after");
first.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.sync.setActive(false);
world.sync.setActive(true);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
expect((await world.nextFetch("agent-a")).request.direction).toBe("after");
world.expectNoPendingMembership();
world.sync.dispose();
});
+274 -17
View File
@@ -6,12 +6,13 @@ import {
import type { CachedTimeline } from "@/runtime/replica-cache";
import {
selectAgentTimelineState,
selectAgentUnsettledTurn,
useSessionStore,
type AgentTimelineCursorState,
type AgentTimelineState,
} from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getSendingClientMessageIds } from "@/composer/submission/model";
import {
getInitDeferred,
@@ -40,6 +41,7 @@ async function prepareCachedTimeline(input: {
agentId: string;
storage: TimelineReplicaStorage;
prepareAgent: (agentId: string) => Promise<void>;
isTurnSettled: () => boolean;
}): Promise<CachedTimeline | undefined> {
const before = useSessionStore.getState().sessions[input.serverId];
const beforeTimeline = selectAgentTimelineState(before, input.agentId);
@@ -87,6 +89,7 @@ async function prepareCachedTimeline(input: {
newer: false,
synchronized: false,
acknowledgedClientMessageIds: replacement.acknowledgedClientMessageIds,
unsettledTurn: input.isTurnSettled() ? undefined : stored.unsettledTurn,
});
return stored;
}
@@ -96,11 +99,16 @@ export interface TimelineReplica {
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
readRange(agentId: string): AgentTimelineCursorState | undefined;
timelineUpdated(agentId: string): void;
catchUpOwed(agentId: string): void;
catchUpSettled(agentId: string): void;
}
class TimelineReplicaOwner implements TimelineReplica {
private readonly cachedRanges = new Map<string, AgentTimelineCursorState>();
private readonly preparations = new Map<string, Promise<void>>();
// A cache read in flight still carries the turn as it was saved. Once the catch-up has
// settled, that turn must not come back with the cached messages.
private readonly settledDuringPreparation = new Set<string>();
constructor(
private readonly serverId: string,
@@ -123,6 +131,7 @@ class TimelineReplicaOwner implements TimelineReplica {
const preparation = this.load(agentId).finally(() => {
if (this.preparations.get(agentId) === preparation) {
this.preparations.delete(agentId);
this.settledDuringPreparation.delete(agentId);
}
});
this.preparations.set(agentId, preparation);
@@ -135,6 +144,7 @@ class TimelineReplicaOwner implements TimelineReplica {
agentId,
storage: this.storage,
prepareAgent: this.prepareAgent,
isTurnSettled: () => this.settledDuringPreparation.has(agentId),
});
if (!stored) return;
if (stored.range) {
@@ -161,8 +171,21 @@ class TimelineReplicaOwner implements TimelineReplica {
items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])],
range: timeline.status === "synced" ? timeline.range : null,
hasOlder: timeline.status === "synced" && timeline.older === "available",
unsettledTurn: selectAgentUnsettledTurn(session, agentId),
});
}
catchUpOwed(agentId: string): void {
this.settledDuringPreparation.delete(agentId);
const store = useSessionStore.getState();
const openTurn = selectAgentUnsettledTurn(store.sessions[this.serverId], agentId);
if (openTurn) store.setAgentTimelineUnsettledTurn(this.serverId, agentId, openTurn);
}
catchUpSettled(agentId: string): void {
if (this.preparations.has(agentId)) this.settledDuringPreparation.add(agentId);
useSessionStore.getState().setAgentTimelineUnsettledTurn(this.serverId, agentId, null);
}
}
export function createTimelineReplica(input: {
@@ -240,6 +263,7 @@ function commitProcessedTimeline(input: {
});
}
store.markAgentHistorySynchronized(serverId, agentId);
if (synchronized) store.setAgentTimelineUnsettledTurn(serverId, agentId, null);
}
function finalizeProcessedTimeline(input: {
@@ -315,10 +339,56 @@ function applyAuthoritativeTimelineResponse(input: {
return true;
}
/**
* Measured on a dev daemon: set_subscription answers in a mean of 1.4 ms, p99 per-window max
* 107 ms, max 227 ms. A check slower than this is slow enough to say so on screen.
*/
export const FRESHNESS_CHECK_QUIET_MS = 500;
export type TimelineFreshness = "current" | "behind";
/** The empty after page the daemon would have returned for a cursor already at the head. */
function buildCurrentTimelinePage(
agentId: string,
cursor: { epoch: string; endSeq: number },
): TimelineResponsePayload {
return {
requestId: "",
agentId,
agent: null,
direction: "after",
projection: "projected",
epoch: cursor.epoch,
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 0, maxSeq: cursor.endSeq, nextSeq: cursor.endSeq + 1 },
startCursor: null,
endCursor: null,
hasOlder: false,
hasNewer: false,
entries: [],
error: null,
};
}
export interface ViewedTimelineSyncPorts {
prepare(agentId: string): Promise<void>;
replaceDemandedAgentIds(agentIds: string[]): void;
observe(agentIds: string[]): { ready: Promise<unknown>; release(): Promise<void> };
/** Whether the daemon answers with each cursor's freshness in the subscribe response. */
canCheckFreshness(): boolean;
observe(
agentIds: string[],
cursors?: Record<string, { epoch: string; seq: number }>,
): {
ready: Promise<{
timelines?: Record<string, TimelineFreshness>;
agents?: Record<string, AgentSnapshotPayload>;
} | void>;
release(): Promise<void>;
};
/** The daemon confirmed the cached timeline is at its head, as a zero-row after page would. */
applyCurrentTimeline(agentId: string): void;
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
fetchPage(
agentId: string,
@@ -330,10 +400,24 @@ export interface ViewedTimelineSyncPorts {
* demanded set. Disconnect and backgrounding keep the obligation and do not report here.
*/
onCatchUpEnded(agentId: string): void;
/** The chat stopped receiving every row, so a catch-up is owed before it is current again. */
onCatchUpOwed(agentId: string): void;
/** The owed catch-up reached current or failed. The chat can take the true turn status. */
onCatchUpSettled(agentId: string): void;
reportError(error: unknown): void;
}
export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying";
/**
* "checking": the daemon has not said whether this chat is current; nothing to report yet.
* "reattaching": the same question right after a reconnect, so the chat is still not back.
*/
export type ViewedTimelineStatus =
| "ready"
| "checking"
| "reattaching"
| "pending"
| "error"
| "retrying";
export interface ViewedTimelineUiBridge {
replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void;
@@ -358,7 +442,12 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
export type ViewedTimelineOwnerPorts = Omit<
ViewedTimelineSyncPorts,
"prepare" | "replaceDemandedAgentIds" | "onCatchUpEnded"
| "prepare"
| "replaceDemandedAgentIds"
| "onCatchUpEnded"
| "onCatchUpOwed"
| "onCatchUpSettled"
| "applyCurrentTimeline"
>;
export interface ViewedTimelineOwner extends ViewedTimelineSync {
@@ -394,8 +483,22 @@ export function createViewedTimelineOwner(input: {
prepare: (agentId) => input.replica.prepare(agentId),
readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId),
replaceDemandedAgentIds: input.replaceDemandedAgentIds,
applyCurrentTimeline: (agentId) => {
const cursor = input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId);
if (!cursor) return;
const accepted = applyAuthoritativeTimelineResponse({
serverId: input.serverId,
payload: buildCurrentTimelinePage(agentId, cursor),
cachedCursor: input.replica.readRange(agentId),
recoverGap: (id, gapCursor) => sync.recoverGap(id, gapCursor),
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
});
if (accepted) input.replica.timelineUpdated(agentId);
},
onCatchUpEnded: (agentId) =>
useCreateFlowStore.getState().clearByAgent({ serverId: input.serverId, agentId }),
onCatchUpOwed: (agentId) => input.replica.catchUpOwed(agentId),
onCatchUpSettled: (agentId) => input.replica.catchUpSettled(agentId),
});
const streamQueue = createSessionAgentStreamReducerQueue({
serverId: input.serverId,
@@ -423,6 +526,7 @@ export function createViewedTimelineOwner(input: {
if (turnLiveness.length === 0) return;
streamQueue.flushAgent(agentId);
input.applyTurnLiveness(agentId, turnLiveness);
input.replica.timelineUpdated(agentId);
},
flushStreamAgent(agentId) {
streamQueue.flushAgent(agentId);
@@ -495,6 +599,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const manualRetries = new Set<string>();
const loadedCache = new Set<string>();
const cacheLoads = new Map<string, Promise<void>>();
// Chats whose freshness answer is outstanding. No catch-up starts for them until it arrives;
// `quiet` holds the status short of "pending" until FRESHNESS_CHECK_QUIET_MS elapses.
const freshnessChecks = new Map<
string,
{ quiet: boolean; reattaching: boolean; timer: ReturnType<typeof setTimeout> | null }
>();
const listeners = new Set<() => void>();
let active = true;
let connected = false;
@@ -507,6 +617,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
let acknowledged: string[] = [];
let membershipGeneration = 0;
let membershipNeedsRetry = false;
let freshnessCheckOwed = false;
// Chats this session has opened. Visibility is the only way in, so an agent is never
// subscribed — and never resumed on the daemon — until the user actually opens it.
// Membership then survives hiding, workspace switches, backgrounding and reconnect;
@@ -537,6 +648,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const wasPending = visibilityCatchUpPending.delete(agentId);
const hadError = visibilityCatchUpErrors.delete(agentId);
const wasRetrying = manualRetries.delete(agentId);
ports.onCatchUpSettled(agentId);
ports.onCatchUpEnded(agentId);
if (wasPending || hadError || wasRetrying) notifyListeners();
};
@@ -545,6 +657,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const message = error instanceof Error ? error.message : String(error);
let changed = false;
for (const agentId of agentIds) {
ports.onCatchUpSettled(agentId);
if (manualRetries.delete(agentId)) changed = true;
if (visibilityCatchUpPending.delete(agentId)) changed = true;
if (visibilityCatchUpErrors.get(agentId) !== message) {
@@ -555,6 +668,38 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (changed) notifyListeners();
};
const markCatchUpOwed = (agentId: string) => {
visibilityCatchUpPending.add(agentId);
ports.onCatchUpOwed(agentId);
};
const startFreshnessCheck = (agentId: string, reattaching = false) => {
if (freshnessChecks.has(agentId)) return;
freshnessChecks.set(agentId, { quiet: true, reattaching, timer: null });
};
// The delay measures the daemon's answer, so it starts when the question is sent. On a cold
// start the cache read that precedes the question can outlast it on its own.
const startFreshnessCheckDelay = (agentId: string) => {
const check = freshnessChecks.get(agentId);
if (!check || check.timer) return;
check.timer = setTimeout(() => {
check.quiet = false;
notifyListeners();
}, FRESHNESS_CHECK_QUIET_MS);
};
const endFreshnessCheck = (agentId: string) => {
const check = freshnessChecks.get(agentId);
if (!check) return;
if (check.timer) clearTimeout(check.timer);
freshnessChecks.delete(agentId);
};
const endAllFreshnessChecks = () => {
for (const agentId of freshnessChecks.keys()) endFreshnessCheck(agentId);
};
const cancelCatchUp = (agentId: string) => {
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
catchUps.delete(agentId);
@@ -644,6 +789,10 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
} = {},
) => {
const { request, supersede = false } = options;
if (freshnessChecks.has(agentId)) {
if (request) pendingCatchUps.set(agentId, request);
return;
}
if (!canCatchUp(agentId)) {
if (request) pendingCatchUps.set(agentId, request);
return;
@@ -698,30 +847,98 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
}
};
const membershipIsSettled = (requested: string[]) =>
!membershipNeedsRetry &&
!freshnessCheckOwed &&
sameAgentIds(requested, observation?.agentIds ?? acknowledged);
// A chat with no fetch running and a cursor to compare; the daemon can answer without a fetch.
const collectFreshnessCursors = (requested: string[]) => {
const cursors: Record<string, { epoch: string; seq: number }> = {};
for (const agentId of requested) {
if (catchUps.has(agentId) || pendingCatchUps.has(agentId)) continue;
const cursor = ports.readCursor(agentId);
if (cursor) cursors[agentId] = { epoch: cursor.epoch, seq: cursor.endSeq };
}
return cursors;
};
const completeCurrentCatchUp = (agentId: string) => {
const generation = (catchUpGenerations.get(agentId) ?? 0) + 1;
catchUpGenerations.set(agentId, generation);
catchUps.set(agentId, { generation, status: "complete" });
ports.applyCurrentTimeline(agentId);
setVisibilityCatchUpReady(agentId);
};
const applyFreshnessAnswer = (
checked: string[],
timelines: Record<string, TimelineFreshness> | undefined,
) => {
for (const agentId of checked) {
endFreshnessCheck(agentId);
// A gap found during the check still needs its fetch; "current" only speaks for the cursor.
if (timelines?.[agentId] === "current" && !pendingCatchUps.has(agentId)) {
completeCurrentCatchUp(agentId);
}
}
if (checked.length > 0) notifyListeners();
};
const isReconcileSuperseded = (generation: number) =>
disposed || !connected || generation !== membershipGeneration;
const askDaemonAboutFreshness = (checked: string[]) => {
for (const agentId of checked) {
startFreshnessCheck(agentId);
startFreshnessCheckDelay(agentId);
}
for (const agentId of freshnessChecks.keys()) {
if (!checked.includes(agentId)) endFreshnessCheck(agentId);
}
};
const reconcileMembership = async (): Promise<void> => {
if (disposed || !connected) return;
const generation = membershipGeneration;
const requested = desired;
if (!membershipNeedsRetry && sameAgentIds(requested, observation?.agentIds ?? acknowledged))
return;
if (membershipIsSettled(requested)) return;
const canCheck = ports.canCheckFreshness();
if (canCheck) {
// The cursor sent to the daemon must include whatever the cache restores.
await Promise.all(requested.map((agentId) => cacheLoads.get(agentId)));
if (isReconcileSuperseded(generation)) return;
if (membershipIsSettled(requested)) return;
}
membershipNeedsRetry = false;
freshnessCheckOwed = false;
const cursors = canCheck ? collectFreshnessCursors(requested) : undefined;
const checked = Object.keys(cursors ?? {});
askDaemonAboutFreshness(checked);
notifyListeners();
let timelines: Record<string, TimelineFreshness> | undefined;
try {
const previous = observation;
const handle = ports.observe(requested);
const handle = ports.observe(requested, cursors);
observation = { handle, agentIds: requested };
void previous?.handle.release().catch(ports.reportError);
await handle.ready;
// A subscription routes events only once the daemon accepts it, so the old one delivers until then.
const answer = await handle.ready.finally(() => {
void previous?.handle.release().catch(ports.reportError);
});
timelines = answer ? answer.timelines : undefined;
} catch (error) {
if (disposed || !connected || generation !== membershipGeneration) return;
if (isReconcileSuperseded(generation)) return;
membershipNeedsRetry = true;
acknowledged = [];
for (const agentId of checked) endFreshnessCheck(agentId);
for (const agentId of requested) cancelCatchUp(agentId);
setVisibilityCatchUpError(requested, error);
ports.reportError(error);
return;
}
if (disposed || !connected || generation !== membershipGeneration) return;
if (isReconcileSuperseded(generation)) return;
acknowledged = requested;
applyFreshnessAnswer(checked, timelines);
startAcknowledgedCatchUps();
};
@@ -749,7 +966,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (options.resetCatchUpStatus) {
for (const agentId of nextDesired) {
if (!visibilityCatchUpPending.has(agentId)) {
visibilityCatchUpPending.add(agentId);
markCatchUpOwed(agentId);
statusChanged = true;
}
if (visibilityCatchUpErrors.delete(agentId)) statusChanged = true;
@@ -765,17 +982,20 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
for (const agentId of desired) {
if (!nextDesired.includes(agentId)) {
cancelCatchUp(agentId);
endFreshnessCheck(agentId);
visibilityCatchUpPending.delete(agentId);
visibilityCatchUpErrors.delete(agentId);
manualRetries.delete(agentId);
ports.onCatchUpOwed(agentId);
released.push(agentId);
}
}
for (const agentId of nextDesired) {
if (!desired.includes(agentId)) {
visibilityCatchUpPending.add(agentId);
markCatchUpOwed(agentId);
visibilityCatchUpErrors.delete(agentId);
manualRetries.delete(agentId);
if (connected && ports.canCheckFreshness()) startFreshnessCheck(agentId);
ensureCacheLoaded(agentId);
}
}
@@ -787,6 +1007,19 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
void reconcileMembership();
};
// A render can see the connection return, or a chat become visible, before this owner hears
// about it, so those two states answer as if the check had already started.
const getUnconfirmedStatus = (agentId: string): ViewedTimelineStatus => {
const check = freshnessChecks.get(agentId);
if (check) {
if (!check.quiet) return "pending";
return check.reattaching ? "reattaching" : "checking";
}
if (!ports.canCheckFreshness()) return "pending";
if (!connected) return "reattaching";
return isDesired(agentId) ? "pending" : "checking";
};
const publishVisibleMembership = () => {
commitDesiredMembership(normalizeAgentIds([...openedAgentIds, ...visibleAgentIds()]));
};
@@ -799,8 +1032,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
getAgentTimelineStatus(agentId) {
if (manualRetries.has(agentId)) return "retrying";
if (visibilityCatchUpErrors.has(agentId)) return "error";
if (!isDesired(agentId) || visibilityCatchUpPending.has(agentId)) return "pending";
return "ready";
if (isDesired(agentId) && !visibilityCatchUpPending.has(agentId)) return "ready";
return getUnconfirmedStatus(agentId);
},
getAgentTimelineError(agentId) {
return visibilityCatchUpErrors.get(agentId) ?? null;
@@ -828,12 +1061,27 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
startAcknowledgedCatchUps();
return;
}
const canCheck = connected && ports.canCheckFreshness();
for (const agentId of visibleAgentIds()) {
if (visibilityCatchUpErrors.has(agentId)) continue;
visibilityCatchUpPending.add(agentId);
visibilityCatchUpErrors.delete(agentId);
markCatchUpOwed(agentId);
if (
canCheck &&
catchUps.get(agentId)?.status === "complete" &&
!pendingCatchUps.has(agentId) &&
ports.readCursor(agentId)
) {
catchUps.delete(agentId);
startFreshnessCheck(agentId);
freshnessCheckOwed = true;
continue;
}
startCatchUp(agentId, { supersede: true });
}
if (freshnessCheckOwed) {
membershipGeneration += 1;
void reconcileMembership();
}
notifyListeners();
},
setConnected(nextConnected) {
@@ -845,10 +1093,18 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
observation = null;
acknowledged = [];
membershipGeneration += 1;
freshnessCheckOwed = false;
endAllFreshnessChecks();
for (const agentId of desired) cancelCatchUp(agentId);
return;
}
if (ports.canCheckFreshness()) {
for (const agentId of desired) {
if (visibilityCatchUpPending.has(agentId)) startFreshnessCheck(agentId, true);
}
}
membershipGeneration += 1;
notifyListeners();
void reconcileMembership();
},
recoverGap(agentId, cursor) {
@@ -875,6 +1131,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
visibilityCatchUpPending.clear();
visibilityCatchUpErrors.clear();
manualRetries.clear();
endAllFreshnessChecks();
notifyListeners();
listeners.clear();
},
+25
View File
@@ -11,6 +11,7 @@ function connection(
broadcasts?: boolean;
browserHost?: { hostKind: string; supportedCommands: string[] };
acknowledgeTimelineReads?: boolean;
timelineFreshnessCheck?: boolean;
} = {},
) {
const sent: Array<{
@@ -47,6 +48,7 @@ function connection(
workspaceMultiplicity: options.workspaceMultiplicity ?? true,
selectiveAgentTimeline: !options.broadcasts,
explicitEventSubscriptions: !options.broadcasts,
timelineFreshnessCheck: options.timelineFreshnessCheck === true,
},
},
},
@@ -887,6 +889,29 @@ test("old attention stream events reach the current notification interface", asy
}
});
test("timeline cursors reach only hosts that answer freshness", async () => {
const cursors = { agent: { epoch: "epoch-1", seq: 9 } };
const sentCursors = async (timelineFreshnessCheck: boolean) => {
const h = connection({ timelineFreshnessCheck });
try {
const connected = h.client.connect();
h.open();
await connected;
const observation = h.client.observeTimeline(["agent"], { cursors });
await observation.ready;
await observation.release();
return h.sent.find(
(frame) => frame.message?.type === "agent.timeline.set_subscription.request",
)?.message;
} finally {
await h.client.close();
}
};
expect(await sentCursors(true)).toMatchObject({ cursors });
expect(await sentCursors(false)).not.toHaveProperty("cursors");
});
test("broadcast-only hosts expose local timeline readiness without sending an unsupported RPC", async () => {
const h = connection({ ownedSubscriptions: false, broadcasts: true });
try {
+14 -2
View File
@@ -4167,11 +4167,23 @@ export class DaemonClient {
observeTimeline(
agentIds: string[],
options?: { signal?: AbortSignal },
options?: {
signal?: AbortSignal;
/** The caller's position per chat; the response answers whether each is at the head. */
cursors?: Record<string, { epoch: string; seq: number }>;
},
): OwnedSubscription<CorrelatedResponsePayload<"agent.timeline.set_subscription.response">> {
return this.observe(
"agent.timeline.set_subscription.response",
{ type: "agent.timeline.set_subscription.request", agentIds },
{
type: "agent.timeline.set_subscription.request",
agentIds,
// COMPAT(timelineFreshnessCheck): added after v0.15.3, remove after 2027-05-09.
...(options?.cursors &&
this.lastServerInfoMessage?.features?.timelineFreshnessCheck === true
? { cursors: options.cursors }
: {}),
},
options,
);
}
+20
View File
@@ -492,6 +492,26 @@ describe("daemon update messages", () => {
});
describe("viewed timeline subscription messages", () => {
test("carries a cursor per chat and a freshness answer per chat", () => {
const cursors = { "agent-a": { epoch: "epoch-1", seq: 42 } };
const timelines = { "agent-a": "current", "agent-b": "behind" };
expect(
SessionInboundMessageSchema.parse({
type: "agent.timeline.set_subscription.request",
agentIds: ["agent-a"],
requestId: "timeline-subscription-2",
cursors,
}),
).toMatchObject({ cursors });
expect(
SessionOutboundMessageSchema.parse({
type: "agent.timeline.set_subscription.response",
payload: { agentIds: ["agent-a"], requestId: "timeline-subscription-2", timelines },
}),
).toMatchObject({ payload: { timelines } });
});
test("parses a complete viewed-agent set and its acknowledgement", () => {
const request = SessionInboundMessageSchema.parse({
type: "agent.timeline.set_subscription.request",
+13
View File
@@ -2087,10 +2087,16 @@ export const ProviderSubagentTimelineRequestMessageSchema = z.object({
limit: z.number().int().nonnegative().optional(),
});
// "current" means the daemon has this agent loaded, the epoch matches, and the cursor is at the head.
// Anything else is "behind", including an agent the daemon has not loaded.
export const TimelineFreshnessSchema = z.enum(["current", "behind"]);
export const SetAgentTimelineSubscriptionRequestMessageSchema = z.object({
type: z.literal("agent.timeline.set_subscription.request"),
agentIds: z.array(z.string()),
requestId: z.string(),
// COMPAT(timelineFreshnessCheck): added after v0.15.3, remove after 2027-05-09.
cursors: z.record(z.string(), AgentTimelineCursorSchema).optional(),
});
export const AgentForkContextRequestMessageSchema = z.object({
@@ -4076,6 +4082,8 @@ export const ServerInfoStatusPayloadSchema = z
forgeProviders: z.boolean().optional(),
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
selectiveAgentTimeline: z.boolean().optional(),
// COMPAT(timelineFreshnessCheck): added after v0.15.3, remove after 2027-05-09.
timelineFreshnessCheck: z.boolean().optional(),
explicitEventSubscriptions: z.boolean().optional(),
ownedSubscriptions: z.boolean().optional(),
providerOptionControls: z.boolean().optional(),
@@ -5249,6 +5257,11 @@ export const SetAgentTimelineSubscriptionResponseMessageSchema = z.object({
subscriptionId: z.string().optional(),
agentIds: z.array(z.string()),
requestId: z.string(),
// COMPAT(timelineFreshnessCheck): added after v0.15.3, remove after 2027-05-09.
timelines: z.record(z.string(), TimelineFreshnessSchema).optional(),
// The agent row for each chat answered "current", which gets no timeline fetch to carry it.
// COMPAT(timelineFreshnessCheck): added after v0.15.3, remove after 2027-05-09.
agents: z.record(z.string(), AgentSnapshotPayloadSchema).optional(),
}),
});
@@ -1399,6 +1399,11 @@ export class AgentManager {
return this.timelineStore.getRows(id);
}
/** The timeline head of a loaded agent, or null when this agent is not loaded. Never loads. */
getLoadedTimelineHead(id: string): { epoch: string; seq: number } | null {
return this.getAgent(id) ? this.timelineStore.getHead(id) : null;
}
fetchTimeline(id: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
this.requireAgent(id);
return this.timelineStore.fetch(id, options);
@@ -89,6 +89,11 @@ export class InMemoryAgentTimelineStore {
return this.requireState(agentId).epoch;
}
getHead(agentId: string): { epoch: string; seq: number } {
const state = this.requireState(agentId);
return { epoch: state.epoch, seq: state.nextSeq - 1 };
}
fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
const state = this.requireState(agentId);
const direction = options?.direction ?? "tail";
@@ -184,6 +184,7 @@ async function connect(input: {
timelineNotifications?: boolean;
pluginTimelineItems?: boolean;
workspaceSetupBlocked?: boolean;
ownedSubscriptions?: boolean;
}): Promise<ConnectedClient> {
let socket!: WebSocket;
const client = new DaemonClient({
@@ -194,7 +195,7 @@ async function connect(input: {
return socket as unknown as WebSocketLike;
},
capabilities: {
[CLIENT_CAPS.ownedSubscriptions]: false,
[CLIENT_CAPS.ownedSubscriptions]: input.ownedSubscriptions ?? false,
[CLIENT_CAPS.selectiveAgentTimeline]: input.selective,
[CLIENT_CAPS.pluginTimelineItems]: input.pluginTimelineItems ?? false,
[CLIENT_CAPS.workspaceSetupBlocked]: input.workspaceSetupBlocked ?? false,
@@ -211,6 +212,97 @@ async function connect(input: {
return connected;
}
async function startDaemonWithMockAgents(
home: { paseoHomeRoot: string; cleanup: boolean } | Record<string, never> = {},
): Promise<void> {
await daemon.close();
daemon = await createTestPaseoDaemon({
isDev: true,
agentClients: { mock: new MockLoadTestAgentClient() },
...home,
});
}
async function connectForFreshness(clientId: string): Promise<ConnectedClient> {
return connect({ clientId, selective: true, ownedSubscriptions: true });
}
async function createChatAtHead(connected: ConnectedClient, title: string) {
const agent = await connected.client.createAgent({
provider: "mock",
cwd: "/tmp",
title,
model: "ten-second-stream",
});
await daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: "Already on screen",
});
const page = await connected.client.fetchAgentTimeline(agent.id, { direction: "tail" });
if (!page.endCursor) throw new Error("Expected a timeline cursor");
return { agentId: agent.id, head: { epoch: page.endCursor.epoch, seq: page.endCursor.seq } };
}
test("subscribing with a cursor answers whether each chat is at its head", async () => {
await startDaemonWithMockAgents();
const connected = await connectForFreshness("freshness-loaded");
const atHead = await createChatAtHead(connected, "At head");
const staleSeq = await createChatAtHead(connected, "Stale seq");
const otherEpoch = await createChatAtHead(connected, "Other epoch");
const noCursor = await createChatAtHead(connected, "No cursor");
const observation = connected.client.observeTimeline(
[atHead.agentId, staleSeq.agentId, otherEpoch.agentId, noCursor.agentId],
{
cursors: {
[atHead.agentId]: atHead.head,
[staleSeq.agentId]: { ...staleSeq.head, seq: staleSeq.head.seq - 1 },
[otherEpoch.agentId]: { ...otherEpoch.head, epoch: "another-epoch" },
},
},
);
const answer = await observation.ready;
expect(answer.timelines).toEqual({
[atHead.agentId]: "current",
[staleSeq.agentId]: "behind",
[otherEpoch.agentId]: "behind",
});
// Only a current chat skips the fetch that would have delivered its agent row.
expect(Object.keys(answer.agents ?? {})).toEqual([atHead.agentId]);
expect(answer.agents?.[atHead.agentId]).toMatchObject({ id: atHead.agentId, title: "At head" });
connected.clear();
await daemon.daemon.agentManager.appendTimelineItem(atHead.agentId, {
type: "assistant_message",
text: "Arrives after the answer",
});
await connected.next(isAgentStream(atHead.agentId), "live row after a current answer");
await observation.release();
});
test("subscribing with a cursor answers behind for a stored agent and leaves it unloaded", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-freshness-"));
await startDaemonWithMockAgents({ paseoHomeRoot, cleanup: false });
const before = await connectForFreshness("freshness-before-restart");
const stored = await createChatAtHead(before, "Stored");
before.close();
await before.client.close();
await startDaemonWithMockAgents({ paseoHomeRoot, cleanup: true });
expect(daemon.daemon.agentManager.getAgent(stored.agentId)).toBeNull();
const connected = await connectForFreshness("freshness-after-restart");
const observation = connected.client.observeTimeline([stored.agentId], {
cursors: { [stored.agentId]: stored.head },
});
const answer = await observation.ready;
expect(answer.timelines).toEqual({ [stored.agentId]: "behind" });
expect(answer.agents).toEqual({});
expect(daemon.daemon.agentManager.getAgent(stored.agentId)).toBeNull();
await observation.release();
});
test("notification timeline items are sent only to clients that advertise support", async () => {
await daemon.close();
daemon = await createTestPaseoDaemon({
+61 -12
View File
@@ -1408,6 +1408,65 @@ export class Session {
this.refreshObservationProducers();
}
private compareTimelineCursors(
agentIds: string[],
cursors: Record<string, AgentTimelineCursor>,
): Record<string, "current" | "behind"> {
const timelines: Record<string, "current" | "behind"> = {};
for (const agentId of agentIds) {
const cursor = cursors[agentId];
if (!cursor) continue;
const head = this.agentManager.getLoadedTimelineHead(agentId);
timelines[agentId] =
head?.epoch === cursor.epoch && head.seq === cursor.seq ? "current" : "behind";
}
return timelines;
}
private async answerTimelineFreshness(
agentIds: string[],
cursors: Record<string, AgentTimelineCursor>,
): Promise<{
timelines: Record<string, "current" | "behind">;
agents: Record<string, AgentSnapshotPayload>;
}> {
const snapshots = await Promise.all(
agentIds.flatMap((agentId) => {
const agent = cursors[agentId] ? this.agentManager.getAgent(agentId) : null;
return agent ? [this.buildAgentPayload(agent)] : [];
}),
);
// Compared after the await, so a row appended while reading the snapshots answers behind.
const timelines = this.compareTimelineCursors(agentIds, cursors);
const agents: Record<string, AgentSnapshotPayload> = {};
for (const snapshot of snapshots) {
if (timelines[snapshot.id] === "current") agents[snapshot.id] = snapshot;
}
return { timelines, agents };
}
private async handleAgentTimelineSubscription(
msg: Extract<SessionInboundMessage, { type: "agent.timeline.set_subscription.request" }>,
source?: object,
): Promise<void> {
const agentIds = [...new Set(msg.agentIds)].sort();
// Attach before comparing: a row appended after the comparison reaches this owner live.
const owner = this.subscribeAgentTimelines(agentIds);
const freshness = msg.cursors ? await this.answerTimelineFreshness(agentIds, msg.cursors) : {};
this.emitForSource(
{
type: "agent.timeline.set_subscription.response",
payload: {
agentIds,
requestId: msg.requestId,
subscriptionId: owner.responseId,
...freshness,
},
},
source,
);
}
private subscribeAgentTimelines(agentIds: string[]): OwnedSubscription {
const owner = this.delivery.begin("timelines", undefined, (id) => {
this.timelineSubscriptions.delete(id);
@@ -3043,18 +3102,8 @@ export class Session {
throw error;
});
}
case "agent.timeline.set_subscription.request": {
const agentIds = [...new Set(msg.agentIds)].sort();
const owner = this.subscribeAgentTimelines(agentIds);
this.emitForSource(
{
type: "agent.timeline.set_subscription.response",
payload: { agentIds, requestId: msg.requestId, subscriptionId: owner.responseId },
},
source,
);
return undefined;
}
case "agent.timeline.set_subscription.request":
return this.handleAgentTimelineSubscription(msg, source);
case "agent.fork_context.request":
return this.handleAgentForkContextRequest(msg);
default:
@@ -2196,6 +2196,8 @@ export class VoiceAssistantWebSocketServer {
forgeProviders: true,
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
selectiveAgentTimeline: true,
// COMPAT(timelineFreshnessCheck): added after v0.15.3, remove after 2027-05-09.
timelineFreshnessCheck: true,
explicitEventSubscriptions: true,
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30.
canonicalSubmittedPrompts: true,