mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 09:54:40 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef19db587e | ||
|
|
130dec624d | ||
|
|
279211e110 | ||
|
|
44b608b854 | ||
|
|
b2b82e5cb0 | ||
|
|
180f07587a |
+38
-8
@@ -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");
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user