feat(sync): keep live data scoped and current

Only viewed chats receive live timeline rows, while directory state now uses one subscribed bootstrap followed by ordered deltas. Legacy clients and daemons retain their existing behavior through centralized compatibility gates.
This commit is contained in:
Mohamed Boudra
2026-07-12 22:18:15 +02:00
parent bce2c50b9e
commit 95f68ef829
37 changed files with 3762 additions and 814 deletions

View File

@@ -0,0 +1,19 @@
import { test } from "./fixtures";
import { DirectoryBootstrapScenario } from "./helpers/directory-bootstrap-scenario";
test.describe("Directory bootstrap correctness", () => {
test("connect, pushed deltas, and reconnect keep directories current without duplicate bootstraps", async ({
page,
}) => {
test.setTimeout(180_000);
const scenario = await DirectoryBootstrapScenario.open(page);
try {
await scenario.expectDirectoryStarts(1);
await scenario.stayConnectedWithoutRefetchAndApplyDeltas();
await scenario.disconnectMutateAndReconnect();
await scenario.expectVisibleReconciliationAndNavigateAgent();
} finally {
await scenario.cleanup();
}
});
});

View File

@@ -0,0 +1,114 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
export interface DirectoryBootstrapCounts {
agents: number;
workspaces: number;
}
export interface DirectoryRequestStartCounts {
subscribed: DirectoryBootstrapCounts;
unsubscribed: DirectoryBootstrapCounts;
total: DirectoryBootstrapCounts;
}
interface ClientRequest {
type?: unknown;
subscribe?: unknown;
page?: { cursor?: unknown };
}
function readClientRequest(message: string | Buffer): ClientRequest | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: ClientRequest;
};
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
} catch {
return null;
}
}
function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCounts | null {
if (request.page?.cursor) return null;
if (request.type === "fetch_agents_request") return "agents";
if (request.type === "fetch_workspaces_request") return "workspaces";
return null;
}
export async function installDaemonWebSocketGate(page: Page) {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
const directoryStarts: DirectoryRequestStartCounts = {
subscribed: { agents: 0, workspaces: 0 },
unsubscribed: { agents: 0, workspaces: 0 },
total: { agents: 0, workspaces: 0 },
};
const clientRequestCounts = new Map<string, number>();
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
return;
}
activeSockets.add(ws);
const server = ws.connectToServer();
ws.onMessage((message) => {
if (!acceptingConnections) return;
const request = readClientRequest(message);
if (typeof request?.type === "string") {
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
const directory = directoryForRequest(request);
if (directory) {
const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed";
directoryStarts[subscription][directory] += 1;
directoryStarts.total[directory] += 1;
}
}
try {
server.send(message);
} catch {
activeSockets.delete(ws);
}
});
server.onMessage((message) => {
if (!acceptingConnections) return;
try {
ws.send(message);
} catch {
activeSockets.delete(ws);
}
});
});
return {
async drop(): Promise<void> {
acceptingConnections = false;
const sockets = Array.from(activeSockets);
activeSockets.clear();
await Promise.all(
sockets.map((ws) =>
ws.close({ code: 1008, reason: "Dropped by reconnect test." }).catch(() => undefined),
),
);
},
restore(): void {
acceptingConnections = true;
},
getDirectoryRequestStartCounts(): DirectoryRequestStartCounts {
return {
subscribed: { ...directoryStarts.subscribed },
unsubscribed: { ...directoryStarts.unsubscribed },
total: { ...directoryStarts.total },
};
},
getClientRequestCount(type: string): number {
return clientRequestCounts.get(type) ?? 0;
},
};
}

View File

@@ -0,0 +1,155 @@
import { expect, type Page } from "@playwright/test";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { seedWorkspace, type SeededWorkspace } from "./seed-client";
import { getServerId } from "./server-id";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./workspace-ui";
interface SeededDirectoryAgent {
id: string;
title: string;
}
async function createRunningMockAgent(
workspace: SeededWorkspace,
title: string,
): Promise<SeededDirectoryAgent> {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "five-minute-stream",
initialPrompt: `Keep ${title} running for directory synchronization.`,
});
const running = await workspace.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30_000,
);
expect(running.status).toBe("running");
return { id: agent.id, title };
}
async function openCommandCenter(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open command center" }).click();
}
export class DirectoryBootstrapScenario {
private readonly workspaces: SeededWorkspace[] = [];
private disconnectedWorkspace: SeededWorkspace | null = null;
private disconnectedAgent: SeededDirectoryAgent | null = null;
private constructor(
private readonly page: Page,
private readonly gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
) {}
static async open(page: Page): Promise<DirectoryBootstrapScenario> {
const gate = await installDaemonWebSocketGate(page);
const scenario = new DirectoryBootstrapScenario(page, gate);
const workspace = await scenario.seedWorkspace("directory-bootstrap-initial-");
const agent = await createRunningMockAgent(workspace, "Initial directory agent");
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, workspace.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
);
await waitForWorkspaceTabsVisible(page);
await expect(page.getByRole("button", { name: agent.title, exact: true })).toBeVisible();
return scenario;
}
async expectDirectoryStarts(expectedPerDirectory: number): Promise<void> {
await expect
.poll(() => this.gate.getDirectoryRequestStartCounts())
.toEqual({
subscribed: { agents: expectedPerDirectory, workspaces: expectedPerDirectory },
unsubscribed: { agents: 0, workspaces: 0 },
total: { agents: expectedPerDirectory, workspaces: expectedPerDirectory },
});
}
async stayConnectedWithoutRefetchAndApplyDeltas(): Promise<void> {
const workspace = await this.seedWorkspace("directory-bootstrap-background-");
const agent = await createRunningMockAgent(workspace, "Background directory agent");
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
await expect(workspaceLink).toHaveCount(1);
await expect(workspaceLink).toBeVisible();
await openCommandCenter(this.page);
const agentLink = this.page.getByText(agent.title, { exact: true });
await expect(agentLink).toHaveCount(1);
await expect(agentLink).toBeVisible();
await this.page.keyboard.press("Escape");
await this.expectDirectoryStarts(1);
}
async disconnectMutateAndReconnect(): Promise<void> {
await this.gate.drop();
await expectReconnectingToastVisible(this.page);
this.disconnectedWorkspace = await this.seedWorkspace("directory-bootstrap-reconnect-");
this.disconnectedAgent = await createRunningMockAgent(
this.disconnectedWorkspace,
"Reconnected directory agent",
);
await expect(
this.page.getByText(this.disconnectedWorkspace.projectDisplayName, { exact: true }),
).toHaveCount(0);
await expect(this.page.getByText(this.disconnectedAgent.title, { exact: true })).toHaveCount(0);
this.gate.restore();
await expectReconnectingToastGone(this.page);
await this.expectDirectoryStarts(2);
}
async expectVisibleReconciliationAndNavigateAgent(): Promise<void> {
const workspace = this.requireDisconnectedWorkspace();
const agent = this.requireDisconnectedAgent();
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
await expect(workspaceLink).toHaveCount(1);
await expect(workspaceLink).toBeVisible();
await openCommandCenter(this.page);
const agentLink = this.page.getByText(agent.title, { exact: true });
await expect(agentLink).toHaveCount(1);
await expect(agentLink).toBeVisible();
await agentLink.click();
await expect(this.page).toHaveURL(
new RegExp(
`/workspace/${workspace.workspaceId}/agent/${agent.id}|/workspace/${workspace.workspaceId}`,
),
);
await expect(this.page.getByRole("button", { name: agent.title, exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
const pings = this.gate.getClientRequestCount("ping");
await expect
.poll(() => this.gate.getClientRequestCount("ping"), { timeout: 30_000 })
.toBeGreaterThan(pings);
await this.expectDirectoryStarts(2);
}
async cleanup(): Promise<void> {
this.gate.restore();
await Promise.all(this.workspaces.map((workspace) => workspace.cleanup()));
}
private async seedWorkspace(prefix: string): Promise<SeededWorkspace> {
const workspace = await seedWorkspace({ repoPrefix: prefix });
this.workspaces.push(workspace);
return workspace;
}
private requireDisconnectedWorkspace(): SeededWorkspace {
if (!this.disconnectedWorkspace) throw new Error("Reconnect workspace was not seeded.");
return this.disconnectedWorkspace;
}
private requireDisconnectedAgent(): SeededDirectoryAgent {
if (!this.disconnectedAgent) throw new Error("Reconnect agent was not seeded.");
return this.disconnectedAgent;
}
}

View File

@@ -0,0 +1,141 @@
import { expect, type Page } from "@playwright/test";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { test } from "./fixtures";
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
expectReconnectingToastVisible,
} from "./helpers/workspace-ui";
interface ViewedTimelineScenario {
client: SeedDaemonClient;
workspaceId: string;
firstAgentId: string;
secondAgentId: string;
cleanup(): Promise<void>;
}
async function seedViewedTimelineScenario(): Promise<ViewedTimelineScenario> {
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
const createAgent = (title: string) =>
workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "ten-second-stream",
});
const [firstAgent, secondAgent] = await Promise.all([
createAgent("First viewed chat"),
createAgent("Second viewed chat"),
]);
return {
client: workspace.client,
workspaceId: workspace.workspaceId,
firstAgentId: firstAgent.id,
secondAgentId: secondAgent.id,
cleanup: workspace.cleanup,
};
}
async function openAgent(page: Page, scenario: ViewedTimelineScenario, agentId: string) {
await page.goto(buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
);
await waitForWorkspaceTabsVisible(page);
}
async function selectAgent(page: Page, title: string) {
await page.getByRole("button", { name: title, exact: true }).click();
}
async function moveActiveTabRight(page: Page) {
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
}
async function commitMessage(scenario: ViewedTimelineScenario, agentId: string, prompt: string) {
await scenario.client.sendAgentMessage(agentId, prompt);
const finish = await scenario.client.waitForFinish(agentId, 30_000);
expect(finish.status).toBe("idle");
}
test.describe("Viewed agent timelines", () => {
test("a hidden retained chat catches up when shown", async ({ page }) => {
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await selectAgent(page, "Second viewed chat");
await commitMessage(
scenario,
scenario.firstAgentId,
"Committed while the first chat is hidden.",
);
await expect(
page.getByText("Committed while the first chat is hidden.", { exact: true }),
).toHaveCount(0);
await selectAgent(page, "First viewed chat");
await expect(
page.getByText("Committed while the first chat is hidden.", { exact: true }),
).toBeVisible();
} finally {
await scenario.cleanup();
}
});
test("two visible split chats both stay current", async ({ page }) => {
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await page.getByRole("button", { name: "Split pane right" }).click();
await selectAgent(page, "Second viewed chat");
await moveActiveTabRight(page);
await expect(
page.getByRole("button", { name: "First viewed chat", exact: true }),
).toHaveAttribute("aria-selected", "true");
await expect(
page.getByRole("button", { name: "Second viewed chat", exact: true }),
).toHaveAttribute("aria-selected", "true");
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(2);
await commitMessage(scenario, scenario.firstAgentId, "First visible pane update.");
await expect(page.getByText("First visible pane update.", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "Second viewed chat", exact: true }),
).toBeVisible();
} finally {
await scenario.cleanup();
}
});
test("a visible chat catches up after reconnecting", async ({ page }) => {
const gate = await installDaemonWebSocketGate(page);
const scenario = await seedViewedTimelineScenario();
try {
await openAgent(page, scenario, scenario.firstAgentId);
await expect(page.getByRole("button", { name: "First viewed chat" })).toHaveAttribute(
"aria-selected",
"true",
);
await gate.drop();
await expectReconnectingToastVisible(page);
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
await expect(
page.getByText("Committed while the chat reconnects.", { exact: true }),
).toHaveCount(0);
gate.restore();
await expectReconnectingToastGone(page);
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
exact: true,
});
await expect(recoveredMessage).toHaveCount(1);
await expect(recoveredMessage).toBeVisible();
} finally {
gate.restore();
await scenario.cleanup();
}
});
});

View File

@@ -1,5 +1,4 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import type { WebSocketRoute } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import {
@@ -34,6 +33,7 @@ import { clickSettingsBackToWorkspace } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { injectDesktopBridge } from "./helpers/desktop-updates";
import { expectAppRoute } from "./helpers/route-assertions";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
@@ -114,61 +114,6 @@ async function expectWorkspaceLocation(
});
}
async function installDaemonWebSocketGate(page: Page) {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." });
return;
}
activeSockets.add(ws);
const server = ws.connectToServer();
ws.onMessage((message) => {
if (!acceptingConnections) {
return;
}
try {
server.send(message);
} catch {
activeSockets.delete(ws);
}
});
server.onMessage((message) => {
if (!acceptingConnections) {
return;
}
try {
ws.send(message);
} catch {
activeSockets.delete(ws);
}
});
});
return {
async drop(): Promise<void> {
acceptingConnections = false;
const sockets = Array.from(activeSockets);
activeSockets.clear();
await Promise.all(
sockets.map((ws) =>
ws
.close({ code: 1008, reason: "Dropped by workspace reconnect regression test." })
.catch(() => undefined),
),
);
},
restore(): void {
acceptingConnections = true;
},
};
}
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });

View File

@@ -6,7 +6,10 @@ import { useTranslation } from "react-i18next";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { refreshAgentInitializationTimeout } from "@/hooks/use-agent-initialization";
import {
createSetAgentInitializing,
refreshAgentInitializationTimeout,
} from "@/hooks/use-agent-initialization";
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
@@ -16,12 +19,8 @@ import {
type TimelineReducerSideEffect,
} from "@/timeline/session-stream-reducers";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import {
isTimelineCatchUpComplete,
planResumeTimelineSync,
planTimelineCatchUpAfter,
planTimelineCatchUpFollowUp,
} from "@/timeline/timeline-sync-plan";
import { isTimelineCatchUpComplete } from "@/timeline/timeline-sync-plan";
import { createViewedTimelineSync, type ViewedTimelineSync } from "@/timeline/viewed-timeline-sync";
import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
import {
@@ -29,7 +28,6 @@ import {
type AgentAttentionNotificationPayload,
type NotificationPermissionRequest,
} from "@getpaseo/protocol/agent-attention-notification";
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import type { GitSetupOptions } from "@getpaseo/protocol/messages";
@@ -54,25 +52,20 @@ import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import {
getInitKey,
getInitDeferred,
createInitDeferred,
resolveInitDeferred,
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { encodeImages } from "@/utils/encode-images";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import {
clearWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
} from "@/contexts/session-workspace-upserts";
import { isNative } from "@/constants/platform";
import { reconcileWorkspaceDirectory } from "@/contexts/workspace-directory-reconciliation";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
@@ -97,7 +90,6 @@ export type {
} from "@/stores/session-store";
const HISTORY_STALE_AFTER_MS = 60_000;
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
function hasAgentUsageChanged(
incomingUsage: Agent["lastUsage"] | undefined,
@@ -127,15 +119,29 @@ interface BufferedAudioChunk {
interface WorkspaceHydrationSnapshot {
workspaces: Map<string, WorkspaceDescriptor>;
emptyProjects: Map<string, EmptyProjectDescriptor>;
deltas: readonly WorkspaceUpdatePayload[];
}
interface WorkspaceHydrationTransaction {
id: symbol;
client: DaemonClient;
workspaces: Map<string, WorkspaceDescriptor>;
deltas: WorkspaceUpdatePayload[];
}
type WorkspaceUpdatePayload = Extract<
SessionOutboundMessage,
{ type: "workspace_update" }
>["payload"];
async function fetchWorkspaceHydrationSnapshot(input: {
client: DaemonClient;
serverId: string;
subscribe: boolean;
isCancelled?: () => boolean;
transaction: WorkspaceHydrationTransaction;
isCurrent: () => boolean;
}): Promise<WorkspaceHydrationSnapshot | null> {
const workspaces = new Map<string, WorkspaceDescriptor>();
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
let cursor: string | null = null;
let includeSubscribe = input.subscribe;
@@ -146,7 +152,7 @@ async function fetchWorkspaceHydrationSnapshot(input: {
...(includeSubscribe ? { subscribe: {} } : {}),
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
if (input.isCancelled?.()) {
if (input.isCancelled?.() || !input.isCurrent()) {
return null;
}
@@ -155,7 +161,7 @@ async function fetchWorkspaceHydrationSnapshot(input: {
if (shouldSuppressWorkspaceForLocalArchive({ serverId: input.serverId, workspace })) {
continue;
}
workspaces.set(workspace.id, workspace);
input.transaction.workspaces.set(workspace.id, workspace);
}
// Project parents with no active workspaces only ride on the first page.
@@ -171,7 +177,11 @@ async function fetchWorkspaceHydrationSnapshot(input: {
includeSubscribe = false;
}
return { workspaces, emptyProjects };
return {
workspaces: new Map(input.transaction.workspaces),
emptyProjects,
deltas: [...input.transaction.deltas],
};
}
function decodeBase64Chunk(base64: string): Uint8Array {
@@ -240,54 +250,11 @@ const getLatestPermissionRequest = (
return null;
};
type AgentUpdatePayload = Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
>["payload"];
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
update.kind === "remove" ? update.agentId : update.agent.id;
// ---------------------------------------------------------------------------
// Module-level pending agent updates buffer (scoped by serverId)
// ---------------------------------------------------------------------------
const pendingAgentUpdates = new Map<string, AgentUpdatePayload>();
function pendingKey(serverId: string, agentId: string): string {
return `${serverId}:${agentId}`;
}
export function bufferPendingAgentUpdate(
serverId: string,
agentId: string,
update: AgentUpdatePayload,
): void {
pendingAgentUpdates.set(pendingKey(serverId, agentId), update);
}
export function flushPendingAgentUpdate(
serverId: string,
agentId: string,
): AgentUpdatePayload | undefined {
const key = pendingKey(serverId, agentId);
const update = pendingAgentUpdates.get(key);
pendingAgentUpdates.delete(key);
return update;
}
export function deletePendingAgentUpdate(serverId: string, agentId: string): void {
pendingAgentUpdates.delete(pendingKey(serverId, agentId));
}
export function clearPendingAgentUpdates(serverId: string): void {
for (const key of Array.from(pendingAgentUpdates.keys())) {
if (key.startsWith(`${serverId}:`)) {
pendingAgentUpdates.delete(key);
}
}
}
type SessionStoreActions = ReturnType<typeof useSessionStore.getState>;
type SetInitializingAgents = SessionStoreActions["setInitializingAgents"];
type SetAgentStreamTail = SessionStoreActions["setAgentStreamTail"];
@@ -401,20 +368,12 @@ function applyTimelineStreamPatches(input: {
function executeTimelineSideEffects(input: {
sideEffects: TimelineReducerSideEffect[];
agentId: string;
serverId: string;
requestCanonicalCatchUp: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
applyAgentUpdatePayload: (payload: AgentUpdatePayload) => void;
recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
}): void {
const { sideEffects, agentId, serverId, requestCanonicalCatchUp, applyAgentUpdatePayload } =
input;
const { sideEffects, agentId, recoverTimelineGap } = input;
for (const effect of sideEffects) {
if (effect.type === "catch_up") {
requestCanonicalCatchUp(agentId, effect.cursor);
} else if (effect.type === "flush_pending_updates") {
const deferredUpdate = flushPendingAgentUpdate(serverId, agentId);
if (deferredUpdate) {
applyAgentUpdatePayload(deferredUpdate);
}
recoverTimelineGap(agentId, effect.cursor);
}
}
}
@@ -553,9 +512,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
const removeWorkspaceSetup = useWorkspaceSetupStore((state) => state.removeWorkspace);
const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer);
@@ -567,32 +526,45 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const focusedTerminalId = useSessionStore(
(state) => state.sessions[serverId]?.focusedTerminalId ?? null,
);
const sessionAgents = useSessionStore((state) => state.sessions[serverId]?.agents);
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
const sendAgentMessageRef = useRef<
| ((
agentId: string,
message: string,
images?: AttachmentMetadata[],
attachments?: AgentAttachment[],
) => Promise<void>)
| null
>(null);
const _sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
const appStateRef = useRef(AppState.currentState);
const revalidationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const revalidationInFlightRef = useRef<Promise<void> | null>(null);
const revalidationQueuedRef = useRef(false);
const timelineCatchUpInFlightRef = useRef<Set<string>>(new Set());
const wasConnectedRef = useRef(isConnected);
const viewedTimelineSyncRef = useRef<ViewedTimelineSync | null>(null);
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
const workspaceHydrationRef = useRef<WorkspaceHydrationTransaction | null>(null);
const applyWorkspaceUpdatePayload = useCallback(
(payload: WorkspaceUpdatePayload) => {
if (payload.kind === "remove") {
clearWorkspaceArchivePending({ serverId, workspaceId: payload.id });
removeWorkspaceSetup({ serverId, workspaceId: payload.id });
removeWorkspace(serverId, payload.id);
if (payload.emptyProject) {
addEmptyProject(serverId, normalizeEmptyProjectDescriptor(payload.emptyProject));
}
if (payload.removedProjectId) removeEmptyProject(serverId, payload.removedProjectId);
return;
}
const workspace = normalizeWorkspaceDescriptor(payload.workspace);
if (!shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
mergeWorkspaces(serverId, [workspace]);
}
},
[
addEmptyProject,
mergeWorkspaces,
removeEmptyProject,
removeWorkspace,
removeWorkspaceSetup,
serverId,
],
);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
appStateRef.current = nextState;
viewedTimelineSyncRef.current?.setActive(getIsAppActivelyVisible(nextState));
});
return () => {
@@ -600,26 +572,35 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
};
}, []);
useEffect(() => {
previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
previousAgentStatusRef.current,
sessionAgents,
);
}, [sessionAgents]);
const hydrateWorkspaces = useCallback(
async (options?: { subscribe?: boolean; isCancelled?: () => boolean }) => {
if (!client || !isConnected) {
return;
}
const snapshot = await fetchWorkspaceHydrationSnapshot({
const transaction: WorkspaceHydrationTransaction = {
id: Symbol("workspace hydration"),
client,
serverId,
subscribe: options?.subscribe ?? false,
isCancelled: options?.isCancelled,
});
workspaces: new Map(),
deltas: [],
};
workspaceHydrationRef.current = transaction;
let snapshot: WorkspaceHydrationSnapshot | null;
try {
snapshot = await fetchWorkspaceHydrationSnapshot({
client,
serverId,
subscribe: options?.subscribe ?? false,
isCancelled: options?.isCancelled,
transaction,
isCurrent: () => workspaceHydrationRef.current?.id === transaction.id,
});
} catch (error) {
if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null;
throw error;
}
if (!snapshot || options?.isCancelled?.()) {
if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null;
return;
}
@@ -631,14 +612,28 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
isCancelled: options?.isCancelled,
});
if (didBackfillLegacy) {
if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null;
return;
}
setWorkspaces(serverId, snapshot.workspaces);
setWorkspaces(
serverId,
reconcileWorkspaceDirectory({ snapshot: snapshot.workspaces, deltas: snapshot.deltas }),
);
setEmptyProjects(serverId, snapshot.emptyProjects.values());
setHasHydratedWorkspaces(serverId, true);
for (const delta of snapshot.deltas) applyWorkspaceUpdatePayload(delta);
if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null;
},
[client, isConnected, serverId, setEmptyProjects, setHasHydratedWorkspaces, setWorkspaces],
[
applyWorkspaceUpdatePayload,
client,
isConnected,
serverId,
setEmptyProjects,
setHasHydratedWorkspaces,
setWorkspaces,
],
);
const applyAuthoritativeAgentSnapshot = useCallback(
@@ -723,168 +718,25 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
return next;
});
const prevStatus = previousAgentStatusRef.current.get(agent.id);
if (prevStatus === "running" && agent.status !== "running") {
const session = useSessionStore.getState().sessions[serverId];
const queue = session?.queuedMessages.get(agent.id);
if (queue && queue.length > 0) {
const [next, ...rest] = queue;
if (sendAgentMessageRef.current) {
const supportsForgeSearch =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch ===
true;
const wirePayload = splitComposerAttachmentsForSubmit(next.attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
});
void sendAgentMessageRef.current(
agent.id,
next.text,
wirePayload.images,
wirePayload.attachments,
);
}
setQueuedMessages(serverId, (prev) => {
const updated = new Map(prev);
updated.set(agent.id, rest);
return updated;
});
}
}
previousAgentStatusRef.current.set(agent.id, agent.status);
},
[
queryClient,
serverId,
setAgentLastActivity,
setAgents,
setPendingPermissions,
setQueuedMessages,
],
[queryClient, serverId, setAgentLastActivity, setAgents, setPendingPermissions],
);
const runAuthoritativeRevalidation = useCallback(async () => {
await Promise.all([
getHostRuntimeStore().refreshAgentDirectory({ serverId }),
hydrateWorkspaces(),
]);
}, [hydrateWorkspaces, serverId]);
const flushAuthoritativeRevalidation = useCallback(() => {
if (!client || !isConnected) {
return;
}
if (revalidationInFlightRef.current) {
revalidationQueuedRef.current = true;
return;
}
const run = runAuthoritativeRevalidation()
.catch((error) => {
console.error("[Session] authoritative revalidation failed", {
serverId,
error,
});
})
.finally(() => {
if (revalidationInFlightRef.current === run) {
revalidationInFlightRef.current = null;
}
if (!revalidationQueuedRef.current) {
return;
}
revalidationQueuedRef.current = false;
if (revalidationTimerRef.current) {
clearTimeout(revalidationTimerRef.current);
}
revalidationTimerRef.current = setTimeout(() => {
revalidationTimerRef.current = null;
flushAuthoritativeRevalidation();
}, AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS);
});
revalidationInFlightRef.current = run;
}, [client, isConnected, runAuthoritativeRevalidation, serverId]);
const scheduleAuthoritativeRevalidation = useCallback(() => {
if (!client || !isConnected) {
return;
}
revalidationQueuedRef.current = true;
if (revalidationTimerRef.current) {
return;
}
revalidationTimerRef.current = setTimeout(() => {
revalidationTimerRef.current = null;
if (!revalidationQueuedRef.current) {
return;
}
revalidationQueuedRef.current = false;
flushAuthoritativeRevalidation();
}, AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS);
}, [client, flushAuthoritativeRevalidation, isConnected]);
const requestCanonicalCatchUp = useCallback(
const recoverTimelineGap = useCallback(
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
const request = planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq });
const key = `${agentId}:${request.cursor.epoch}:${request.cursor.seq}`;
const inFlight = timelineCatchUpInFlightRef.current;
if (inFlight.has(key)) {
return;
}
inFlight.add(key);
void client
.fetchAgentTimeline(agentId, request)
.catch((error) => {
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
})
.finally(() => {
inFlight.delete(key);
});
viewedTimelineSyncRef.current?.recoverGap(agentId, cursor);
},
[client],
[],
);
const handleAppResumed = useCallback(
(awayMs: number) => {
scheduleAuthoritativeRevalidation();
if (isNative) {
const session = useSessionStore.getState().sessions[serverId];
const agentId = session?.focusedAgentId;
if (agentId) {
const plan = planResumeTimelineSync({
cursor: session?.agentTimelineCursor.get(agentId),
});
if (plan.direction === "after") {
requestCanonicalCatchUp(agentId, {
epoch: plan.cursor.epoch,
endSeq: plan.cursor.seq,
});
} else {
void client.fetchAgentTimeline(agentId, plan).catch((error) => {
console.warn("[Session] failed to fetch tail timeline on resume", agentId, error);
});
}
}
}
if (awayMs < HISTORY_STALE_AFTER_MS) {
return;
}
bumpHistorySyncGeneration(serverId);
},
[
bumpHistorySyncGeneration,
client,
requestCanonicalCatchUp,
scheduleAuthoritativeRevalidation,
serverId,
],
[bumpHistorySyncGeneration, serverId],
);
// Client activity tracking (heartbeat, push token registration)
@@ -944,11 +796,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
// Initialize session in store
useEffect(() => {
initializeSession(serverId, client);
const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0;
initializeSession(serverId, client, generation);
}, [serverId, client, initializeSession]);
useEffect(() => {
updateSessionClient(serverId, client);
const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0;
updateSessionClient(serverId, client, generation);
}, [serverId, client, updateSessionClient]);
useEffect(() => {
@@ -1024,7 +878,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
useEffect(() => {
if (!isConnected) {
flushAgentLastActivity();
clearPendingAgentUpdates(serverId);
setInitializingAgents(serverId, new Map());
}
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
@@ -1051,85 +904,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
};
}, [client, hydrateWorkspaces, isConnected]);
const applyAgentUpdatePayload = useCallback(
(update: AgentUpdatePayload) => {
if (update.kind === "remove") {
const agentId = update.agentId;
previousAgentStatusRef.current.delete(agentId);
deletePendingAgentUpdate(serverId, agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
});
setPendingPermissions(serverId, (prev) => {
if (prev.size === 0) {
return prev;
}
let changed = false;
const next = new Map(prev);
for (const [key, pending] of Array.from(next.entries())) {
if (pending.agentId === agentId) {
next.delete(key);
changed = true;
}
}
return changed ? next : prev;
});
setQueuedMessages(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
});
setAgentTimelineCursor(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
});
setAgentAuthoritativeHistoryApplied(serverId, agentId, false);
return;
}
const normalized = normalizeAgentSnapshot(update.agent, serverId);
const agent = applyLegacyDaemonWorkspaceOwnership({
serverId,
agent: {
...normalized,
projectPlacement: resolveProjectPlacement({
projectPlacement: update.project,
cwd: normalized.cwd,
}),
},
});
applyAuthoritativeAgentSnapshot(agent);
},
[
applyAuthoritativeAgentSnapshot,
queryClient,
serverId,
setAgentAuthoritativeHistoryApplied,
setAgents,
setAgentTimelineCursor,
setPendingPermissions,
setQueuedMessages,
],
);
const applyWorkspaceSetupProgress = useCallback(
(payload: WorkspaceSetupProgressPayload) => {
upsertWorkspaceSetupProgress({ serverId, payload });
@@ -1222,34 +996,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
executeTimelineSideEffects({
sideEffects: result.sideEffects,
agentId,
serverId,
requestCanonicalCatchUp,
applyAgentUpdatePayload,
recoverTimelineGap,
});
const followUp = planTimelineCatchUpFollowUp({
direction: payload.direction,
hasNewer: payload.hasNewer,
endCursor: payload.endCursor,
error: payload.error,
});
if (followUp?.direction === "after") {
refreshAgentInitializationTimeout({
key: initKey,
agentId,
setAgentInitializing: (id, initializing) => {
if (initializing) {
return;
}
clearAgentInitializingFlag(setInitializingAgents, serverId, id);
},
});
requestCanonicalCatchUp(agentId, {
epoch: followUp.cursor.epoch,
endSeq: followUp.cursor.seq,
});
}
finalizeTimelineApplication({
result,
agentId,
@@ -1263,10 +1012,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
},
[
applyAuthoritativeAgentSnapshot,
applyAgentUpdatePayload,
clearAgentStreamHead,
markAgentHistorySynchronized,
requestCanonicalCatchUp,
recoverTimelineGap,
serverId,
setAgentAuthoritativeHistoryApplied,
setAgentStreamHead,
@@ -1278,54 +1026,67 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
);
useEffect(() => {
if (isConnected) {
return;
}
clearPendingAgentUpdates(serverId);
}, [isConnected, serverId]);
const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents);
const sync = createViewedTimelineSync({
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
readCursor: (agentId) =>
useSessionStore.getState().sessions[serverId]?.agentTimelineCursor.get(agentId),
fetchPage: async (agentId, request) => {
const session = useSessionStore.getState().sessions[serverId];
const initKey = getInitKey(serverId, agentId);
if (
session?.agentAuthoritativeHistoryApplied.get(agentId) !== true &&
!getInitDeferred(initKey)
) {
createInitDeferred(initKey, request.direction ?? "tail");
refreshAgentInitializationTimeout({
key: initKey,
agentId,
setAgentInitializing,
});
setAgentInitializing(agentId, true);
}
try {
return await client.fetchAgentTimeline(agentId, request);
} catch (error) {
setAgentInitializing(agentId, false);
rejectInitDeferred(initKey, error instanceof Error ? error : new Error(String(error)));
throw error;
}
},
reportError: (error) => {
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
},
scheduleRetry: (retry) => {
const timeout = setTimeout(retry, 1_000);
return () => clearTimeout(timeout);
},
});
viewedTimelineSyncRef.current = sync;
setViewedTimelineSync(serverId, sync);
sync.setActive(getIsAppActivelyVisible(appStateRef.current));
useEffect(() => {
const wasConnected = wasConnectedRef.current;
wasConnectedRef.current = isConnected;
if (!wasConnected && isConnected) {
scheduleAuthoritativeRevalidation();
}
}, [isConnected, scheduleAuthoritativeRevalidation]);
useEffect(() => {
return () => {
if (revalidationTimerRef.current) {
clearTimeout(revalidationTimerRef.current);
if (viewedTimelineSyncRef.current === sync) {
viewedTimelineSyncRef.current = null;
}
setViewedTimelineSync(serverId, null);
sync.dispose();
};
}, []);
}, [client, serverId, setInitializingAgents, setViewedTimelineSync]);
useEffect(() => {
viewedTimelineSyncRef.current?.setConnected(isConnected);
}, [isConnected]);
// Daemon message handlers - directly update Zustand store
useEffect(() => {
const unsubAgentUpdate = client.on("agent_update", (message) => {
if (message.type !== "agent_update") return;
const update = message.payload;
const agentId = getAgentIdFromUpdate(update);
const initKey = getInitKey(serverId, agentId);
const session = useSessionStore.getState().sessions[serverId];
const isSyncingHistory =
session?.initializingAgents.get(agentId) === true && Boolean(getInitDeferred(initKey));
if (isSyncingHistory) {
bufferPendingAgentUpdate(serverId, agentId, update);
return;
}
deletePendingAgentUpdate(serverId, agentId);
applyAgentUpdatePayload(update);
});
const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({
serverId,
setAgentStreamState,
setAgentTimelineCursor,
setAgents,
requestCanonicalCatchUp,
recoverTimelineGap,
});
const unsubAgentStream = client.on("agent_stream", (message) => {
@@ -1342,18 +1103,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
}
// Attention notification stays in React (not extractable to pure reducer)
if (event.type === "attention_required") {
if (event.shouldNotify) {
notifyAgentAttention({
agentId,
reason: event.reason,
timestamp: event.timestamp,
notification: event.notification,
});
}
}
agentStreamReducerQueue.enqueue(agentId, {
event: streamEvent,
seq,
@@ -1366,6 +1115,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
// on status changes, which is sufficient for sorting and display purposes.
});
const unsubAgentAttention = client.onAgentAttentionRequired((notification) => {
if (notification.shouldNotify) {
notifyAgentAttention(notification);
}
});
const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => {
if (message.type !== "fetch_agent_timeline_response") return;
agentStreamReducerQueue.flushAgent(message.payload.agentId);
@@ -1379,26 +1134,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
if (message.type !== "workspace_update") return;
if (message.payload.kind === "remove") {
clearWorkspaceArchivePending({
serverId,
workspaceId: message.payload.id,
});
removeWorkspaceSetup({ serverId, workspaceId: message.payload.id });
removeWorkspace(serverId, message.payload.id);
if (message.payload.emptyProject) {
addEmptyProject(serverId, normalizeEmptyProjectDescriptor(message.payload.emptyProject));
}
if (message.payload.removedProjectId) {
removeEmptyProject(serverId, message.payload.removedProjectId);
}
const hydration = workspaceHydrationRef.current;
if (hydration) {
hydration.deltas.push(message.payload);
return;
}
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
return;
}
mergeWorkspaces(serverId, [workspace]);
applyWorkspaceUpdatePayload(message.payload);
});
const unsubScriptStatusUpdate = client.on("script_status_update", (message) => {
@@ -1667,7 +1408,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
return;
}
const { agentId } = message.payload;
deletePendingAgentUpdate(serverId, agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => {
@@ -1780,10 +1520,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
});
return () => {
unsubAgentUpdate();
unsubAgentStream();
unsubAgentTimeline();
unsubProviderSubagentUpdate();
unsubAgentAttention();
unsubWorkspaceUpdate();
unsubScriptStatusUpdate();
unsubCheckoutStatusUpdate();
@@ -1827,8 +1567,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setHasHydratedAgents,
clearDraftInput,
notifyAgentAttention,
requestCanonicalCatchUp,
applyAgentUpdatePayload,
recoverTimelineGap,
applyWorkspaceUpdatePayload,
applyWorkspaceSetupProgress,
applyTimelineResponse,
updateSessionServerInfo,
@@ -1837,67 +1577,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
voiceAudioEngine,
]);
const sendAgentMessage = useCallback(
async (
agentId: string,
message: string,
images?: AttachmentMetadata[],
attachments?: AgentAttachment[],
) => {
const messageId = generateMessageId();
const userMessage: StreamItem = {
kind: "user_message",
id: messageId,
text: message,
timestamp: new Date(),
optimistic: true,
...(images && images.length > 0 ? { images } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
};
// Append to head if streaming (keeps the user message with the current
// turn so late text_deltas still find the existing assistant_message).
// Otherwise append to tail.
const currentHead = useSessionStore
.getState()
.sessions[serverId]?.agentStreamHead?.get(agentId);
if (currentHead && currentHead.length > 0) {
setAgentStreamHead(serverId, (prev) => {
const head = prev.get(agentId) || [];
const updated = new Map(prev);
updated.set(agentId, [...head, userMessage]);
return updated;
});
} else {
setAgentStreamTail(serverId, (prev) => {
const currentStream = prev.get(agentId) || [];
const updated = new Map(prev);
updated.set(agentId, [...currentStream, userMessage]);
return updated;
});
}
const imagesData = await encodeImages(images);
if (!client) {
console.warn("[Session] sendAgentMessage skipped: daemon unavailable");
return;
}
void client
.sendAgentMessage(agentId, message, {
messageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
})
.catch((error) => {
console.error("[Session] Failed to send agent message:", error);
});
},
[serverId, client, setAgentStreamTail, setAgentStreamHead],
);
// Keep the ref updated so the agent_update handler can call it
sendAgentMessageRef.current = sendAgentMessage;
const _cancelAgentRun = useCallback(
(agentId: string) => {
if (!client) {

View File

@@ -0,0 +1,41 @@
import { expect, it } from "vitest";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import { normalizeWorkspaceDescriptor } from "@/stores/session-store";
import { reconcileWorkspaceDirectory } from "./workspace-directory-reconciliation";
function workspace(id: string, title: string): WorkspaceDescriptorPayload {
return {
id,
projectId: "project",
projectDisplayName: "Project",
projectRootPath: "/repo",
workspaceDirectory: `/repo/${id}`,
projectKind: "git",
workspaceKind: "worktree",
name: id,
title,
status: "done",
activityAt: null,
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
it("keeps workspace upserts and removals received during later pages", () => {
const result = reconcileWorkspaceDirectory({
snapshot: new Map([
["updated", normalizeWorkspaceDescriptor(workspace("updated", "snapshot"))],
["removed", normalizeWorkspaceDescriptor(workspace("removed", "snapshot"))],
]),
deltas: [
{ kind: "upsert", workspace: workspace("updated", "live") },
{ kind: "remove", id: "removed" },
],
});
expect(Array.from(result.values()).map(({ id, title }) => [id, title])).toEqual([
["updated", "live"],
]);
});

View File

@@ -0,0 +1,20 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
type WorkspaceDelta = Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
export function reconcileWorkspaceDirectory(input: {
snapshot: ReadonlyMap<string, WorkspaceDescriptor>;
deltas: readonly WorkspaceDelta[];
}): Map<string, WorkspaceDescriptor> {
const workspaces = new Map(input.snapshot);
for (const delta of input.deltas) {
if (delta.kind === "remove") {
workspaces.delete(delta.id);
} else {
const workspace = normalizeWorkspaceDescriptor(delta.workspace);
workspaces.set(workspace.id, workspace);
}
}
return workspaces;
}

View File

@@ -26,7 +26,7 @@ import {
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
@@ -40,10 +40,7 @@ import {
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import {
clearHistorySyncErrorAfterSuccessfulSync,
reconcileMissingAgentStateWithPresentAgent,
} from "@/panels/agent-panel-load-state";
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
@@ -782,44 +779,6 @@ function ChatAgentContent({
mode: "translate",
});
const handleHistorySyncFailure = useCallback(
({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => {
if (agentId) {
console.warn("[AgentPanel] history sync failed", {
origin,
agentId,
error,
});
}
const message = toErrorMessage(error);
setMissingAgentState((previous) => {
if (previous.kind === "error" && previous.message === message) {
return previous;
}
return { kind: "error", message };
});
},
[agentId],
);
const ensureInitializedWithSyncErrorHandling = useCallback(
(origin: "focus" | "entry") => {
if (!agentId) {
return;
}
ensureAgentIsInitialized(agentId)
.then(() => {
setMissingAgentState(clearHistorySyncErrorAfterSuccessfulSync);
return undefined;
})
.catch((error) => {
handleHistorySyncFailure({ origin, error });
return undefined;
});
},
[agentId, ensureAgentIsInitialized, handleHistorySyncFailure],
);
useEffect(() => {
if (connectionStatus === "online") {
if (reconnectToastArmedRef.current) {
@@ -840,13 +799,6 @@ function ChatAgentContent({
}
}, [connectionStatus, dismissToast, toastApi, t]);
useEffect(() => {
if (!isPaneFocused || !agentId || !isConnected || !hasSession) {
return;
}
ensureInitializedWithSyncErrorHandling("focus");
}, [agentId, ensureInitializedWithSyncErrorHandling, hasSession, isConnected, isPaneFocused]);
const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId }));
useEffect(() => {
@@ -946,27 +898,6 @@ function ChatAgentContent({
streamViewRef.current?.scrollToBottom("message-sent");
}, [agentId]);
useEffect(() => {
if (!agentId) {
return;
}
if (!isConnected || !hasSession) {
return;
}
const shouldSyncOnEntry = needsAuthoritativeSync || isNative;
if (!shouldSyncOnEntry) {
return;
}
ensureInitializedWithSyncErrorHandling("entry");
}, [
agentId,
ensureInitializedWithSyncErrorHandling,
hasSession,
isConnected,
needsAuthoritativeSync,
]);
useEffect(() => {
initAttemptTokenRef.current += 1;
setMissingAgentState({ kind: "idle" });

View File

@@ -1,8 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.hoisted(() => {
Object.defineProperty(globalThis, "__DEV__", { value: false, configurable: true });
});
import type {
DaemonClient,
ConnectionState,
@@ -10,8 +6,13 @@ import type {
FetchAgentsOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import {
HostRuntimeController,
HostRuntimeStore,
@@ -20,10 +21,6 @@ import {
type HostRuntimeStorage,
} from "./host-runtime";
vi.mock("@/browser-automation/handler", () => ({
mountBrowserAutomationDaemonClientHandler: vi.fn(() => () => undefined),
}));
class FakeDaemonClient {
private state: ConnectionState = { status: "idle" };
private listeners = new Set<(status: ConnectionState) => void>();
@@ -33,7 +30,43 @@ class FakeDaemonClient {
private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = [];
public connectCalls = 0;
public fetchAgentsCalls: FetchAgentsOptions[] = [];
public fetchAgentsResponses: Awaited<ReturnType<DaemonClient["fetchAgents"]>>[] = [];
public fetchAgentsResponses: Array<
Awaited<ReturnType<DaemonClient["fetchAgents"]>> | ReturnType<DaemonClient["fetchAgents"]>
> = [];
public sentAgentMessages: Array<Parameters<DaemonClient["sendAgentMessage"]>> = [];
private agentUpdateListeners = new Set<
(message: Extract<SessionOutboundMessage, { type: "agent_update" }>) => void
>();
private fetchWaiters = new Set<() => void>();
private agentListenerWaiters = new Set<() => void>();
private sentMessageWaiters = new Set<() => void>();
on(
type: "agent_update",
listener: (message: Extract<SessionOutboundMessage, { type: "agent_update" }>) => void,
): () => void {
if (type === "agent_update") this.agentUpdateListeners.add(listener);
for (const waiter of this.agentListenerWaiters) waiter();
return () => this.agentUpdateListeners.delete(listener);
}
async waitForAgentUpdates(): Promise<void> {
if (this.agentUpdateListeners.size > 0) return;
await new Promise<void>((resolve) => {
const waiter = () => {
if (this.agentUpdateListeners.size === 0) return;
this.agentListenerWaiters.delete(waiter);
resolve();
};
this.agentListenerWaiters.add(waiter);
});
}
agentUpdate(payload: Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"]): void {
for (const listener of this.agentUpdateListeners) {
listener({ type: "agent_update", payload });
}
}
async connect(): Promise<void> {
this.connectCalls += 1;
@@ -44,6 +77,23 @@ class FakeDaemonClient {
this.setConnectionState({ status: "disconnected", reason: "client_closed" });
}
async sendAgentMessage(...args: Parameters<DaemonClient["sendAgentMessage"]>): Promise<void> {
this.sentAgentMessages.push(args);
for (const waiter of this.sentMessageWaiters) waiter();
}
async waitForSentMessages(count: number): Promise<void> {
if (this.sentAgentMessages.length >= count) return;
await new Promise<void>((resolve) => {
const waiter = () => {
if (this.sentAgentMessages.length < count) return;
this.sentMessageWaiters.delete(waiter);
resolve();
};
this.sentMessageWaiters.add(waiter);
});
}
ensureConnected(): void {
if (this.state.status !== "connected") {
this.setConnectionState({ status: "connected" });
@@ -70,9 +120,10 @@ class FakeDaemonClient {
options?: FetchAgentsOptions,
): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
this.fetchAgentsCalls.push(options ?? {});
for (const waiter of this.fetchWaiters) waiter();
const queued = this.fetchAgentsResponses.shift();
if (queued) {
return queued;
return await queued;
}
return makeFetchAgentsPayload({
entries: [],
@@ -80,6 +131,18 @@ class FakeDaemonClient {
});
}
async waitForFetches(count: number): Promise<void> {
if (this.fetchAgentsCalls.length >= count) return;
await new Promise<void>((resolve) => {
const waiter = () => {
if (this.fetchAgentsCalls.length < count) return;
this.fetchWaiters.delete(waiter);
resolve();
};
this.fetchWaiters.add(waiter);
});
}
async ping(): Promise<{ rttMs: number }> {
return { rttMs: 0 };
}
@@ -160,6 +223,32 @@ function makeFetchAgentsPayload(input: {
};
}
class Deferred<T> {
readonly promise: Promise<T>;
private resolvePromise!: (value: T) => void;
constructor() {
this.promise = new Promise((resolve) => {
this.resolvePromise = resolve;
});
}
resolve(value: T): void {
this.resolvePromise(value);
}
}
async function waitForDirectoryReady(store: HostRuntimeStore, serverId: string): Promise<void> {
if (store.getSnapshot(serverId)?.agentDirectoryStatus === "ready") return;
await new Promise<void>((resolve) => {
const unsubscribe = store.subscribe(serverId, () => {
if (store.getSnapshot(serverId)?.agentDirectoryStatus !== "ready") return;
unsubscribe();
resolve();
});
});
}
function makeFetchAgentsEntry(input: {
id: string;
cwd: string;
@@ -220,6 +309,14 @@ function makeFetchAgentsEntry(input: {
};
}
function replicaAgent(snapshot: FetchAgentsEntry["agent"], serverId: string): Agent {
return { ...normalizeAgentSnapshot(snapshot, serverId), projectPlacement: null };
}
function agentPermission(id: string): AgentPermissionRequest {
return { id, provider: "codex", name: id, kind: "tool", title: id };
}
function makeHost(input?: Partial<HostProfile>): HostProfile {
const direct: HostConnection = {
id: "direct:lan:6767",
@@ -1322,13 +1419,11 @@ describe("HostRuntimeStore", () => {
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
const timeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
@@ -1342,11 +1437,18 @@ describe("HostRuntimeStore", () => {
expect(snapshot?.agentDirectoryStatus).toBe("ready");
expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true);
await store.refreshAgentDirectory({ serverId: host.serverId });
expect(fakeClient.fetchAgentsCalls[1]).toEqual({
scope: "active",
sort: [{ key: "updated_at", direction: "desc" }],
page: { limit: 200 },
});
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("bootstraps agent directory immediately when connection goes online (no session required)", async () => {
it("waits for the matching session replica before committing the connected client bootstrap", async () => {
const host = makeHost({
serverId: "srv_no_session",
connections: [
@@ -1373,10 +1475,14 @@ describe("HostRuntimeStore", () => {
store.syncHosts([host]);
const timeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
await Promise.resolve();
expect(fakeClient.fetchAgentsCalls).toEqual([]);
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
@@ -1387,6 +1493,7 @@ describe("HostRuntimeStore", () => {
});
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("bootstraps legacy daemons from unscoped agents and creates path-backed workspaces", async () => {
@@ -1428,7 +1535,7 @@ describe("HostRuntimeStore", () => {
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.updateSessionServerInfo(host.serverId, {
serverId: host.serverId,
hostname: null,
@@ -1436,14 +1543,8 @@ describe("HostRuntimeStore", () => {
});
store.syncHosts([host]);
const timeoutAt = Date.now() + 300;
while (
(fakeClient.fetchAgentsCalls.length === 0 ||
!useSessionStore.getState().sessions[host.serverId]?.workspaces.has("/repo/legacy-app")) &&
Date.now() < timeoutAt
) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
expect(fakeClient.fetchAgentsCalls).toEqual([
{
@@ -1466,6 +1567,98 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("drains legacy snapshot and buffered running transitions exactly once", async () => {
const host = makeHost({ serverId: "srv_legacy_transitions" });
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const pageTwo = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
const snapshotAgent = makeFetchAgentsEntry({
id: "legacy-snapshot",
cwd: "/legacy/repo",
updatedAt: "2026-07-12T10:00:00.000Z",
});
const bufferedAgent = makeFetchAgentsEntry({
id: "legacy-buffered",
cwd: "/legacy/repo",
updatedAt: "2026-07-12T10:00:00.000Z",
});
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [
{ ...snapshotAgent, agent: { ...snapshotAgent.agent, status: "idle" } },
{ ...bufferedAgent, agent: { ...bufferedAgent.agent, status: "running" } },
],
hasMore: true,
nextCursor: "legacy-page-two",
}),
pageTwo.promise,
);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_legacy_transitions",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.updateSessionServerInfo(host.serverId, {
serverId: host.serverId,
hostname: null,
version: "0.1.96",
});
sessionStore.setAgents(
host.serverId,
new Map([
[
"legacy-snapshot",
{ ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" },
],
[
"legacy-buffered",
{ ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" },
],
]),
);
sessionStore.setQueuedMessages(
host.serverId,
new Map([
["legacy-snapshot", [{ id: "legacy-snapshot-message", text: "snapshot", attachments: [] }]],
["legacy-buffered", [{ id: "legacy-buffered-message", text: "buffered", attachments: [] }]],
]),
);
store.syncHosts([host]);
await fakeClient.waitForFetches(2);
fakeClient.agentUpdate({
kind: "upsert",
agent: { ...bufferedAgent.agent, status: "idle" },
project: bufferedAgent.project,
});
pageTwo.resolve(makeFetchAgentsPayload({ entries: [] }));
await waitForDirectoryReady(store, host.serverId);
await fakeClient.waitForSentMessages(2);
expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([
["legacy-snapshot", "snapshot"],
["legacy-buffered", "buffered"],
]);
expect(
Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map(
({ id, status, workspaceId }) => [id, status, workspaceId],
),
).toEqual([
["legacy-snapshot", "idle", "/legacy/repo"],
["legacy-buffered", "idle", "/legacy/repo"],
]);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("fetches all pages during bootstrap within the active agent scope", async () => {
const host = makeHost({
serverId: "srv_paged",
@@ -1521,13 +1714,11 @@ describe("HostRuntimeStore", () => {
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
const timeoutAt = Date.now() + 300;
while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
await fakeClient.waitForFetches(2);
await waitForDirectoryReady(store, host.serverId);
expect(fakeClient.fetchAgentsCalls).toHaveLength(2);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
@@ -1542,16 +1733,9 @@ describe("HostRuntimeStore", () => {
page: { limit: 200, cursor: "cursor-page-2" },
});
let staleAgent =
const staleAgent =
useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ??
null;
const staleTimeoutAt = Date.now() + 300;
while (!staleAgent && Date.now() < staleTimeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
staleAgent =
useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ??
null;
}
expect(staleAgent?.requiresAttention).toBe(true);
expect(staleAgent?.attentionReason).toBe("error");
@@ -1563,6 +1747,444 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("replays agent updates received while a later bootstrap page is loading", async () => {
const host = makeHost({ serverId: "srv_paged_delta" });
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
let finishPageTwo!: (payload: Awaited<ReturnType<DaemonClient["fetchAgents"]>>) => void;
const pageTwo = new Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>((resolve) => {
finishPageTwo = resolve;
});
const pageOneAgent = makeFetchAgentsEntry({
id: "agent-a",
cwd: "/repo",
updatedAt: "2026-07-12T10:00:00.000Z",
title: "snapshot",
});
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [pageOneAgent],
hasMore: true,
nextCursor: "page-two",
subscriptionId: "app:srv_paged_delta",
}),
pageTwo,
);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_paged_delta",
},
});
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
await fakeClient.waitForFetches(2);
fakeClient.agentUpdate({
kind: "upsert",
agent: { ...pageOneAgent.agent, title: "live" },
project: pageOneAgent.project,
});
finishPageTwo(
makeFetchAgentsPayload({
entries: [
makeFetchAgentsEntry({
id: "agent-b",
cwd: "/repo",
updatedAt: "2026-07-12T09:00:00.000Z",
}),
],
}),
);
await waitForDirectoryReady(store, host.serverId);
expect(
Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map(
(agent) => [agent.id, agent.title],
),
).toEqual([
["agent-a", "live"],
["agent-b", null],
]);
const agentB = makeFetchAgentsEntry({
id: "agent-b",
cwd: "/repo",
updatedAt: "2026-07-12T11:00:00.000Z",
title: "immediate",
});
fakeClient.agentUpdate({ kind: "upsert", agent: agentB.agent, project: agentB.project });
expect(useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-b")?.title).toBe(
"immediate",
);
fakeClient.agentUpdate({ kind: "remove", agentId: "agent-b" });
expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-b")).toBe(false);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("buffers updates until the matching session generation exists", async () => {
const host = makeHost({ serverId: "srv_pre_session" });
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const snapshotEntry = makeFetchAgentsEntry({
id: "agent-pre-session",
cwd: "/repo",
updatedAt: "2026-07-12T10:00:00.000Z",
title: "snapshot",
});
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [snapshotEntry] }));
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_pre_session",
},
});
store.syncHosts([host]);
await fakeClient.waitForAgentUpdates();
fakeClient.agentUpdate({
kind: "upsert",
agent: { ...snapshotEntry.agent, title: "before-session" },
project: snapshotEntry.project,
});
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
expect(
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-pre-session")?.title,
).toBe("before-session");
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("rejects a superseded refresh without overwriting the newer replica", async () => {
const host = makeHost({ serverId: "srv_overlap" });
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [] }));
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_overlap",
},
});
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
const olderPage = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
fakeClient.fetchAgentsResponses.push(olderPage.promise);
const olderRefresh = store.refreshAgentDirectory({ serverId: host.serverId });
await fakeClient.waitForFetches(2);
const newerEntry = makeFetchAgentsEntry({
id: "newer",
cwd: "/repo",
updatedAt: "2026-07-12T11:00:00.000Z",
});
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [newerEntry] }));
const newerRefresh = store.refreshAgentDirectory({ serverId: host.serverId });
await fakeClient.waitForFetches(3);
await newerRefresh;
olderPage.resolve(
makeFetchAgentsPayload({
entries: [
makeFetchAgentsEntry({
id: "older",
cwd: "/repo",
updatedAt: "2026-07-12T09:00:00.000Z",
}),
],
}),
);
await expect(olderRefresh).rejects.toThrow();
fakeClient.agentUpdate({
kind: "upsert",
agent: { ...newerEntry.agent, title: "after cleanup" },
project: newerEntry.project,
});
expect(
Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map(
({ id, title }) => [id, title],
),
).toEqual([["newer", "after cleanup"]]);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("rejects a refresh when the session generation changes before commit", async () => {
const host = makeHost({ serverId: "srv_stale_generation" });
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const existingEntry = makeFetchAgentsEntry({
id: "existing",
cwd: "/repo",
updatedAt: "2026-07-12T10:00:00.000Z",
});
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [existingEntry] }));
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_stale_generation",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
const stalePage = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
fakeClient.fetchAgentsResponses.push(stalePage.promise);
const refresh = store.refreshAgentDirectory({ serverId: host.serverId });
await fakeClient.waitForFetches(2);
sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 2);
stalePage.resolve(
makeFetchAgentsPayload({
entries: [
makeFetchAgentsEntry({
id: "stale",
cwd: "/repo",
updatedAt: "2026-07-12T11:00:00.000Z",
}),
],
}),
);
await expect(refresh).rejects.toThrow();
expect(
Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.keys() ?? []),
).toEqual(["existing"]);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("drains queued messages once for snapshot and buffered running transitions", async () => {
const host = makeHost({ serverId: "srv_queued_transitions" });
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const pageTwo = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
const snapshotAgent = makeFetchAgentsEntry({
id: "snapshot-transition",
cwd: "/repo",
updatedAt: "2026-07-12T10:00:00.000Z",
});
const bufferedAgent = makeFetchAgentsEntry({
id: "buffered-transition",
cwd: "/repo",
updatedAt: "2026-07-12T10:00:00.000Z",
});
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [
{ ...snapshotAgent, agent: { ...snapshotAgent.agent, status: "idle" } },
{ ...bufferedAgent, agent: { ...bufferedAgent.agent, status: "running" } },
],
hasMore: true,
nextCursor: "page-two",
}),
pageTwo.promise,
);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_queued_transitions",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.setAgents(
host.serverId,
new Map([
[
"snapshot-transition",
{ ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" },
],
[
"buffered-transition",
{ ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" },
],
]),
);
sessionStore.setQueuedMessages(
host.serverId,
new Map([
[
"snapshot-transition",
[{ id: "message-snapshot", text: "snapshot queued", attachments: [] }],
],
[
"buffered-transition",
[{ id: "message-buffered", text: "buffered queued", attachments: [] }],
],
]),
);
store.syncHosts([host]);
await fakeClient.waitForFetches(2);
fakeClient.agentUpdate({
kind: "upsert",
agent: { ...bufferedAgent.agent, status: "idle" },
project: bufferedAgent.project,
});
pageTwo.resolve(makeFetchAgentsPayload({ entries: [] }));
await waitForDirectoryReady(store, host.serverId);
await fakeClient.waitForSentMessages(2);
expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([
["snapshot-transition", "snapshot queued"],
["buffered-transition", "buffered queued"],
]);
expect(
Array.from(useSessionStore.getState().sessions[host.serverId]?.queuedMessages.values() ?? []),
).toEqual([[], []]);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("applies buffered stale side effects from the accepted page agent", async () => {
const host = makeHost({ serverId: "srv_buffered_stale_side_effects" });
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const pageTwo = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
const base = makeFetchAgentsEntry({
id: "stale-side-effects",
cwd: "/repo",
updatedAt: "2026-07-12T12:00:00.000Z",
title: "newer page",
});
const pageAgent = {
...base.agent,
status: "running" as const,
lastUsage: { inputTokens: 10, outputTokens: 5 },
pendingPermissions: [agentPermission("current-permission")],
};
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [{ ...base, agent: pageAgent }],
hasMore: true,
nextCursor: "page-two",
}),
pageTwo.promise,
);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_buffered_stale_side_effects",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.setAgents(
host.serverId,
new Map([
[
pageAgent.id,
replicaAgent({ ...pageAgent, updatedAt: "2026-07-12T10:00:00.000Z" }, host.serverId),
],
]),
);
sessionStore.setAgentLastActivity(pageAgent.id, new Date("2026-07-12T12:00:00.000Z"));
sessionStore.flushAgentLastActivity();
setAgentArchiving({
queryClient,
serverId: host.serverId,
agentId: pageAgent.id,
isArchiving: true,
});
store.syncHosts([host]);
await fakeClient.waitForFetches(2);
fakeClient.agentUpdate({
kind: "upsert",
agent: {
...pageAgent,
status: "idle",
title: "stale live",
updatedAt: "2026-07-12T11:00:00.000Z",
lastUsage: { inputTokens: 20, outputTokens: 8 },
pendingPermissions: [agentPermission("stale-permission")],
archivedAt: "2026-07-12T11:00:00.000Z",
},
project: base.project,
});
pageTwo.resolve(makeFetchAgentsPayload({ entries: [] }));
await waitForDirectoryReady(store, host.serverId);
sessionStore.flushAgentLastActivity();
const state = useSessionStore.getState();
const agent = state.sessions[host.serverId]?.agents.get(pageAgent.id);
expect({
title: agent?.title,
status: agent?.status,
usage: agent?.lastUsage,
permissions: Array.from(state.sessions[host.serverId]?.pendingPermissions.values() ?? []).map(
({ request }) => request.id,
),
archivePending: isAgentArchiving({
queryClient,
serverId: host.serverId,
agentId: pageAgent.id,
}),
activity: state.agentLastActivity.get(pageAgent.id)?.toISOString(),
sentMessages: fakeClient.sentAgentMessages.length,
}).toEqual({
title: "newer page",
status: "running",
usage: { inputTokens: 20, outputTokens: 8 },
permissions: ["current-permission"],
archivePending: true,
activity: "2026-07-12T12:00:00.000Z",
sentMessages: 0,
});
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("re-subscribes agent directory updates after reconnect", async () => {
const host = makeHost({
serverId: "srv_resubscribe",
@@ -1590,13 +2212,16 @@ describe("HostRuntimeStore", () => {
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
const initialTimeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length < 1 && Date.now() < initialTimeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
fakeClient.setConnectionState({ status: "connected" });
await Promise.resolve();
await Promise.resolve();
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
fakeClient.setConnectionState({
status: "disconnected",
@@ -1604,10 +2229,8 @@ describe("HostRuntimeStore", () => {
});
fakeClient.setConnectionState({ status: "connected" });
const reconnectTimeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < reconnectTimeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
await fakeClient.waitForFetches(2);
await waitForDirectoryReady(store, host.serverId);
expect(fakeClient.fetchAgentsCalls).toEqual([
{
@@ -1661,7 +2284,7 @@ describe("HostRuntimeStore", () => {
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
useSessionStore.getState().setAgents(host.serverId, () => {
const stale = makeFetchAgentsEntry({
id: "agent-archived",
@@ -1686,13 +2309,8 @@ describe("HostRuntimeStore", () => {
store.syncHosts([host]);
const timeoutAt = Date.now() + 300;
while (
useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived") &&
Date.now() < timeoutAt
) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived")).toBe(
false,

View File

@@ -40,11 +40,18 @@ import {
import { getDesktopHost } from "@/desktop/host";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore } from "@/stores/session-store";
import {
fetchLegacyDaemonWorkspaceDirectory,
applyAgentDirectoryDelta,
replaceFetchedAgentDirectory,
type AgentDirectoryDelta,
} from "@/utils/agent-directory-sync";
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
readLegacyDaemonWorkspaceDirectory,
replaceLegacyDaemonWorkspaceDirectory,
shouldUseLegacyDaemonWorkspaceDirectory,
stampLegacyWorkspaceIds,
} from "@/workspace/legacy-daemon-workspaces";
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
import { queryClient } from "@/data/query-client";
@@ -54,6 +61,8 @@ import {
} from "@/data/push-router";
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { encodeImages } from "@/utils/encode-images";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRegistryStatus = "loading" | "ready";
@@ -217,10 +226,13 @@ interface AgentDirectoryFetchInput {
filter?: FetchAgentsOptions["filter"];
subscribe?: FetchAgentsOptions["subscribe"];
page?: FetchAgentsOptions["page"];
transaction: AgentDirectoryTransaction;
isCurrent: () => boolean;
}
interface AgentDirectoryFetchResult {
entries: FetchAgentsEntry[];
deltas: readonly AgentDirectoryDelta[];
subscriptionId: string | null;
}
@@ -231,7 +243,6 @@ async function fetchCurrentAgentDirectory(
let cursor = input.page?.cursor ?? null;
let includeSubscribe = true;
let subscriptionId: string | null = null;
const entries: FetchAgentsEntry[] = [];
while (true) {
const payload = await input.client.fetchAgents({
@@ -242,7 +253,8 @@ async function fetchCurrentAgentDirectory(
page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit },
});
entries.push(...payload.entries);
if (!input.isCurrent()) throw new AgentDirectoryRefreshSupersededError();
input.transaction.entries.push(...payload.entries);
subscriptionId = subscriptionId ?? payload.subscriptionId ?? null;
includeSubscribe = false;
@@ -257,7 +269,11 @@ async function fetchCurrentAgentDirectory(
cursor = nextCursor;
}
return { entries, subscriptionId };
return {
entries: input.transaction.entries,
deltas: input.transaction.deltas,
subscriptionId,
};
}
function toActiveConnection(connection: HostConnection): ActiveConnection {
@@ -1393,6 +1409,28 @@ function rekeyMap<V>(map: Map<string, V>, oldKey: string, newKey: string): void
map.set(newKey, value);
}
interface AgentDirectoryTransaction {
id: symbol;
client: DaemonClient;
clientGeneration: number;
entries: FetchAgentsEntry[];
deltas: AgentDirectoryDelta[];
}
interface AgentDirectoryRefreshInput {
serverId: string;
filter?: FetchAgentsOptions["filter"];
subscribe?: FetchAgentsOptions["subscribe"];
page?: FetchAgentsOptions["page"];
}
interface AgentDirectoryRefreshResult {
agents: ReturnType<typeof replaceFetchedAgentDirectory>["agents"];
subscriptionId: string | null;
}
class AgentDirectoryRefreshSupersededError extends Error {}
export class HostRuntimeStore {
private controllers = new Map<string, HostRuntimeController>();
private serverListeners = new Map<string, Set<() => void>>();
@@ -1406,6 +1444,11 @@ export class HostRuntimeStore {
private deps: HostRuntimeControllerDeps;
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
private agentDirectoryTransactions = new Map<string, AgentDirectoryTransaction>();
private agentDirectoryListeners = new Map<
string,
{ client: DaemonClient; generation: number; unsubscribe: () => void }
>();
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
private bootStarted = false;
private storage: HostRuntimeStorage;
@@ -1926,6 +1969,9 @@ export class HostRuntimeStore {
this.controllers.delete(serverId);
this.lastConnectionStatusByServer.delete(serverId);
this.agentDirectoryBootstrapInFlight.delete(serverId);
this.agentDirectoryTransactions.delete(serverId);
this.agentDirectoryListeners.get(serverId)?.unsubscribe();
this.agentDirectoryListeners.delete(serverId);
void controller.stop();
this.emit(serverId);
}
@@ -1980,6 +2026,11 @@ export class HostRuntimeStore {
return;
}
const snapshot = controller.getSnapshot();
this.installAgentDirectoryListener({
serverId,
client: snapshot.client,
generation: snapshot.clientGeneration,
});
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus);
const didTransitionOnline =
@@ -2006,14 +2057,11 @@ export class HostRuntimeStore {
return;
}
const bootstrap = Promise.resolve()
.then(() =>
this.refreshAgentDirectory({
serverId,
subscribe: { subscriptionId: `app:${serverId}` },
page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT },
}),
)
const bootstrap = this.refreshAgentDirectory({
serverId,
subscribe: { subscriptionId: `app:${serverId}` },
page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT },
})
.then(() => undefined)
.catch((error) => {
console.error("[HostRuntime] agent directory bootstrap failed", {
@@ -2031,6 +2079,74 @@ export class HostRuntimeStore {
this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap);
}
private installAgentDirectoryListener(input: {
serverId: string;
client: DaemonClient | null;
generation: number;
}): void {
const current = this.agentDirectoryListeners.get(input.serverId);
if (current?.client === input.client && current.generation === input.generation) return;
current?.unsubscribe();
this.agentDirectoryListeners.delete(input.serverId);
this.agentDirectoryTransactions.delete(input.serverId);
if (!input.client) return;
const client = input.client;
const unsubscribe = client.on("agent_update", (message) => {
if (message.type !== "agent_update") return;
const installed = this.agentDirectoryListeners.get(input.serverId);
if (installed?.client !== client || installed.generation !== input.generation) return;
const transaction = this.agentDirectoryTransactions.get(input.serverId);
if (transaction?.client === client && transaction.clientGeneration === input.generation) {
transaction.deltas.push(message.payload);
return;
}
const session = useSessionStore.getState().sessions[input.serverId];
if (session?.client !== client || session.clientGeneration !== input.generation) return;
this.applyAgentDirectoryDelta(input.serverId, message.payload);
});
this.agentDirectoryListeners.set(input.serverId, {
client,
generation: input.generation,
unsubscribe,
});
}
private applyAgentDirectoryDelta(serverId: string, delta: AgentDirectoryDelta): void {
const result = applyAgentDirectoryDelta({ serverId, delta });
if (result.stoppedRunning) {
this.drainQueuedAgentMessage(serverId, result.agentId);
}
}
private drainQueuedAgentMessage(serverId: string, agentId: string): void {
const store = useSessionStore.getState();
const session = store.sessions[serverId];
const queue = session?.queuedMessages.get(agentId);
if (!session?.client || !queue?.length) return;
const [next, ...rest] = queue;
const wirePayload = splitComposerAttachmentsForSubmit(next.attachments);
store.setQueuedMessages(serverId, (current) => {
const updated = new Map(current);
updated.set(agentId, rest);
return updated;
});
void encodeImages(wirePayload.images)
.then((images) =>
session.client?.sendAgentMessage(agentId, next.text, {
messageId: next.id,
...(images && images.length > 0 ? { images } : {}),
attachments: wirePayload.attachments,
}),
)
.catch((error) => {
console.error("[HostRuntime] failed to drain queued agent message", {
serverId,
agentId,
error: toErrorMessage(error),
});
});
}
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
return this.controllers.get(serverId)?.getSnapshot() ?? null;
}
@@ -2095,15 +2211,9 @@ export class HostRuntimeStore {
).then(() => undefined);
}
async refreshAgentDirectory(input: {
serverId: string;
filter?: FetchAgentsOptions["filter"];
subscribe?: FetchAgentsOptions["subscribe"];
page?: FetchAgentsOptions["page"];
}): Promise<{
agents: ReturnType<typeof replaceFetchedAgentDirectory>["agents"];
subscriptionId: string | null;
}> {
async refreshAgentDirectory(
input: AgentDirectoryRefreshInput,
): Promise<AgentDirectoryRefreshResult> {
const controller = this.controllers.get(input.serverId);
if (!controller) {
throw new Error(`Unknown host runtime for serverId ${input.serverId}`);
@@ -2114,43 +2224,186 @@ export class HostRuntimeStore {
throw new Error(`Host ${input.serverId} is not connected`);
}
const transaction: AgentDirectoryTransaction = {
id: Symbol("agent directory refresh"),
client,
clientGeneration: snapshot.clientGeneration,
entries: [],
deltas: [],
};
this.agentDirectoryTransactions.set(input.serverId, transaction);
const hasMatchingSession = () => {
const session = useSessionStore.getState().sessions[input.serverId];
return (
session?.client === client && session.clientGeneration === transaction.clientGeneration
);
};
if (!hasMatchingSession()) {
await new Promise<void>((resolve, reject) => {
const unsubscribeStore = useSessionStore.subscribe((state) => {
const session = state.sessions[input.serverId];
if (
session?.client === client &&
session.clientGeneration === transaction.clientGeneration
) {
unsubscribeStore();
unsubscribeController();
resolve();
}
});
const unsubscribeController = controller.subscribe(() => {
if (controller.getClient() !== client) {
unsubscribeStore();
unsubscribeController();
reject(new Error(`Host ${input.serverId} client changed before directory bootstrap`));
}
});
});
}
controller.markAgentDirectorySyncLoading();
try {
const session = useSessionStore.getState().sessions[input.serverId];
if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) {
const result = await fetchLegacyDaemonWorkspaceDirectory({
return await this.refreshLegacyAgentDirectory({
input,
controller,
client,
serverId: input.serverId,
subscribe: input.subscribe,
page: input.page,
transaction,
hasMatchingSession,
});
controller.markAgentDirectorySyncReady();
return {
agents: result.agents,
subscriptionId: result.subscriptionId,
};
}
const directory = await fetchCurrentAgentDirectory({
return await this.refreshCurrentAgentDirectory({
input,
controller,
client,
filter: input.filter,
subscribe: input.subscribe,
page: input.page,
transaction,
hasMatchingSession,
});
const { agents } = replaceFetchedAgentDirectory({
serverId: input.serverId,
entries: directory.entries,
});
controller.markAgentDirectorySyncReady();
return {
agents,
subscriptionId: directory.subscriptionId,
};
} catch (error) {
controller.markAgentDirectorySyncError(toErrorMessage(error));
if (!(error instanceof AgentDirectoryRefreshSupersededError)) {
controller.markAgentDirectorySyncError(toErrorMessage(error));
}
throw error;
} finally {
if (this.agentDirectoryTransactions.get(input.serverId)?.id === transaction.id) {
this.agentDirectoryTransactions.delete(input.serverId);
}
}
}
private async refreshLegacyAgentDirectory(context: {
input: AgentDirectoryRefreshInput;
controller: HostRuntimeController;
client: DaemonClient;
transaction: AgentDirectoryTransaction;
hasMatchingSession: () => boolean;
}): Promise<AgentDirectoryRefreshResult> {
const { input, controller, client, transaction, hasMatchingSession } = context;
const directory = await readLegacyDaemonWorkspaceDirectory({
client,
subscribe: input.subscribe,
page: input.page,
});
if (
!directory ||
this.agentDirectoryTransactions.get(input.serverId)?.id !== transaction.id ||
!hasMatchingSession()
) {
throw new AgentDirectoryRefreshSupersededError();
}
const previous = useSessionStore.getState().sessions[input.serverId]?.agents ?? new Map();
const stampedSnapshot = stampLegacyWorkspaceIds(directory.entries);
const reconciled = reconcileAgentDirectory({
previous,
snapshot: stampedSnapshot,
deltas: transaction.deltas,
});
const result = replaceLegacyDaemonWorkspaceDirectory({
serverId: input.serverId,
entries: reconciled.entries,
});
this.applyAgentDirectoryCommitSideEffects({
serverId: input.serverId,
previous,
directory: {
entries: stampedSnapshot,
deltas: transaction.deltas,
subscriptionId: directory.subscriptionId,
},
stoppedRunningAgentIds: reconciled.stoppedRunningAgentIds,
});
this.agentDirectoryBootstrapInFlight.delete(input.serverId);
controller.markAgentDirectorySyncReady();
return { agents: result.agents, subscriptionId: directory.subscriptionId };
}
private async refreshCurrentAgentDirectory(context: {
input: AgentDirectoryRefreshInput;
controller: HostRuntimeController;
client: DaemonClient;
transaction: AgentDirectoryTransaction;
hasMatchingSession: () => boolean;
}): Promise<AgentDirectoryRefreshResult> {
const { input, controller, client, transaction, hasMatchingSession } = context;
const directory = await fetchCurrentAgentDirectory({
client,
filter: input.filter,
subscribe: input.subscribe,
page: input.page,
transaction,
isCurrent: () =>
this.agentDirectoryTransactions.get(input.serverId)?.id === transaction.id &&
controller.getSnapshot().client === client &&
controller.getSnapshot().clientGeneration === transaction.clientGeneration,
});
if (
this.agentDirectoryTransactions.get(input.serverId)?.id !== transaction.id ||
controller.getSnapshot().client !== client ||
controller.getSnapshot().clientGeneration !== transaction.clientGeneration ||
!hasMatchingSession()
) {
throw new AgentDirectoryRefreshSupersededError();
}
const previous = useSessionStore.getState().sessions[input.serverId]?.agents ?? new Map();
const reconciled = reconcileAgentDirectory({
previous,
snapshot: directory.entries,
deltas: directory.deltas,
});
const { agents } = replaceFetchedAgentDirectory({
serverId: input.serverId,
entries: reconciled.entries,
});
this.applyAgentDirectoryCommitSideEffects({
serverId: input.serverId,
previous,
directory,
stoppedRunningAgentIds: reconciled.stoppedRunningAgentIds,
});
this.agentDirectoryBootstrapInFlight.delete(input.serverId);
controller.markAgentDirectorySyncReady();
return { agents, subscriptionId: directory.subscriptionId };
}
private applyAgentDirectoryCommitSideEffects(input: {
serverId: string;
previous: ReadonlyMap<string, Agent>;
directory: AgentDirectoryFetchResult;
stoppedRunningAgentIds: string[];
}): void {
const snapshotAgentIds = new Set(input.directory.entries.map((entry) => entry.agent.id));
for (const agentId of input.previous.keys()) {
if (!snapshotAgentIds.has(agentId)) {
applyAgentDirectoryDelta({ serverId: input.serverId, delta: { kind: "remove", agentId } });
}
}
for (const delta of input.directory.deltas) {
applyAgentDirectoryDelta({ serverId: input.serverId, delta });
}
for (const agentId of input.stoppedRunningAgentIds) {
this.drainQueuedAgentMessage(input.serverId, agentId);
}
}

View File

@@ -0,0 +1,112 @@
import { expect, test } from "vitest";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { selectVisibleAgentIds } from "./visible-agent-ids";
test("selects only the active agent tab in every visible pane", () => {
const layout: WorkspaceLayout = {
focusedPaneId: "left",
root: {
kind: "group",
group: {
id: "root",
direction: "horizontal",
sizes: [0.5, 0.5],
children: [
{ kind: "pane", pane: { id: "left", tabIds: ["a", "hidden"], focusedTabId: "a" } },
{ kind: "pane", pane: { id: "right", tabIds: ["b"], focusedTabId: "b" } },
],
},
},
};
const tabs: WorkspaceTab[] = [
{ tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "hidden", target: { kind: "agent", agentId: "agent-hidden" }, createdAt: 2 },
{ tabId: "b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 3 },
];
expect(
selectVisibleAgentIds({ layout, tabs, routeFocused: true, focusedPaneOnly: false }),
).toEqual(["agent-a", "agent-b"]);
});
test("route blur publishes no viewed agents", () => {
const layout: WorkspaceLayout = {
focusedPaneId: "main",
root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } },
};
const tabs: WorkspaceTab[] = [
{ tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
];
expect(
selectVisibleAgentIds({ layout, tabs, routeFocused: false, focusedPaneOnly: false }),
).toEqual([]);
});
test("compact and focus modes contribute only the focused pane", () => {
const layout: WorkspaceLayout = {
focusedPaneId: "right",
root: {
kind: "group",
group: {
id: "root",
direction: "horizontal",
sizes: [0.5, 0.5],
children: [
{ kind: "pane", pane: { id: "left", tabIds: ["a"], focusedTabId: "a" } },
{ kind: "pane", pane: { id: "right", tabIds: ["b"], focusedTabId: "b" } },
],
},
},
};
const tabs: WorkspaceTab[] = [
{ tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
];
expect(
selectVisibleAgentIds({ layout, tabs, routeFocused: true, focusedPaneOnly: true }),
).toEqual(["agent-b"]);
});
test("pane retargeting replaces the viewed agent and duplicate panes collapse to one ID", () => {
const layout: WorkspaceLayout = {
focusedPaneId: "left",
root: {
kind: "group",
group: {
id: "root",
direction: "horizontal",
sizes: [0.5, 0.5],
children: [
{ kind: "pane", pane: { id: "left", tabIds: ["active"], focusedTabId: "active" } },
{ kind: "pane", pane: { id: "right", tabIds: ["duplicate"], focusedTabId: "duplicate" } },
],
},
},
};
const duplicateTabs: WorkspaceTab[] = [
{ tabId: "active", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "duplicate", target: { kind: "agent", agentId: "agent-a" }, createdAt: 2 },
];
const retargetedTabs: WorkspaceTab[] = [
{ tabId: "active", target: { kind: "agent", agentId: "agent-b" }, createdAt: 1 },
{ tabId: "duplicate", target: { kind: "agent", agentId: "agent-a" }, createdAt: 2 },
];
expect({
duplicate: selectVisibleAgentIds({
layout,
tabs: duplicateTabs,
routeFocused: true,
focusedPaneOnly: false,
}),
retargeted: selectVisibleAgentIds({
layout,
tabs: retargetedTabs,
routeFocused: true,
focusedPaneOnly: false,
}),
}).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] });
});

View File

@@ -0,0 +1,27 @@
import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { deriveWorkspacePaneState } from "./workspace-pane-state";
export function selectVisibleAgentIds(input: {
layout: WorkspaceLayout | null;
tabs: WorkspaceTab[];
routeFocused: boolean;
focusedPaneOnly: boolean;
}): string[] {
if (!input.routeFocused || !input.layout) {
return [];
}
const panes = input.focusedPaneOnly
? collectAllPanes(input.layout.root).filter((pane) => pane.id === input.layout?.focusedPaneId)
: collectAllPanes(input.layout.root);
return [
...new Set(
panes.flatMap((pane) => {
const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor
.target;
return target?.kind === "agent" ? [target.agentId] : [];
}),
),
].sort();
}

View File

@@ -89,6 +89,7 @@ import {
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/workspace-tabs/identity";
import { selectVisibleAgentIds } from "./visible-agent-ids";
import {
getHostRuntimeStore,
useHostRuntimeClient,
@@ -2013,6 +2014,31 @@ function WorkspaceScreenContent({
}),
[uiTabs, workspaceLayout],
);
const viewedTimelineSync = useSessionStore(
(state) => state.sessions[normalizedServerId]?.viewedTimelineSync ?? null,
);
const visibleAgentIds = useMemo(
() =>
selectVisibleAgentIds({
layout: workspaceLayout,
tabs: uiTabs,
routeFocused: isRouteFocused,
focusedPaneOnly: isMobile || isFocusModeEnabled,
}),
[isFocusModeEnabled, isMobile, isRouteFocused, uiTabs, workspaceLayout],
);
useEffect(() => {
if (!persistenceKey || !viewedTimelineSync) {
return;
}
viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, visibleAgentIds);
}, [persistenceKey, viewedTimelineSync, visibleAgentIds]);
useEffect(() => {
if (!persistenceKey || !viewedTimelineSync) {
return;
}
return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []);
}, [persistenceKey, viewedTimelineSync]);
const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId);
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
const focusedPaneAgentId = useMemo(() => {

View File

@@ -2,6 +2,7 @@ import equal from "fast-deep-equal";
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import {
handoffCreatedAgentUserMessageToStream,
@@ -325,6 +326,8 @@ export interface SessionState {
// Daemon client (immutable reference)
client: DaemonClient | null;
clientGeneration: number;
viewedTimelineSync: ViewedTimelineUiBridge | null;
// Server metadata (from server_info handshake)
serverInfo: DaemonServerInfo | null;
@@ -390,10 +393,11 @@ interface SessionStoreState {
// Action types
interface SessionStoreActions {
// Session management
initializeSession: (serverId: string, client: DaemonClient) => void;
initializeSession: (serverId: string, client: DaemonClient, clientGeneration?: number) => void;
clearSession: (serverId: string) => void;
getSession: (serverId: string) => SessionState | undefined;
updateSessionClient: (serverId: string, client: DaemonClient) => void;
updateSessionClient: (serverId: string, client: DaemonClient, clientGeneration?: number) => void;
setViewedTimelineSync: (serverId: string, sync: ViewedTimelineUiBridge | null) => void;
updateSessionServerInfo: (serverId: string, info: DaemonServerInfo) => void;
// Audio state
@@ -531,10 +535,16 @@ type SessionStore = SessionStoreState & SessionStoreActions;
const agentLastActivityCoalescer = createAgentLastActivityCoalescer();
// Helper to create initial session state
function createInitialSessionState(serverId: string, client: DaemonClient): SessionState {
function createInitialSessionState(
serverId: string,
client: DaemonClient,
clientGeneration = 0,
): SessionState {
return {
serverId,
client,
clientGeneration,
viewedTimelineSync: null,
serverInfo: null,
hasHydratedAgents: false,
hasHydratedWorkspaces: false,
@@ -637,7 +647,7 @@ export const useSessionStore = create<SessionStore>()(
agentLastActivity: new Map(),
// Session management
initializeSession: (serverId, client) => {
initializeSession: (serverId, client, clientGeneration) => {
set((prev) => {
if (prev.sessions[serverId]) {
return prev;
@@ -646,7 +656,7 @@ export const useSessionStore = create<SessionStore>()(
...prev,
sessions: {
...prev.sessions,
[serverId]: createInitialSessionState(serverId, client),
[serverId]: createInitialSessionState(serverId, client, clientGeneration),
},
};
});
@@ -686,7 +696,7 @@ export const useSessionStore = create<SessionStore>()(
});
},
updateSessionClient: (serverId, client) => {
updateSessionClient: (serverId, client, clientGeneration = 0) => {
set((prev) => {
const session = prev.sessions[serverId];
@@ -694,7 +704,7 @@ export const useSessionStore = create<SessionStore>()(
return prev;
}
if (session.client === client) {
if (session.client === client && session.clientGeneration === clientGeneration) {
return prev;
}
@@ -705,12 +715,29 @@ export const useSessionStore = create<SessionStore>()(
[serverId]: {
...session,
client,
clientGeneration,
},
},
};
});
},
setViewedTimelineSync: (serverId, viewedTimelineSync) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session || session.viewedTimelineSync === viewedTimelineSync) {
return prev;
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, viewedTimelineSync },
},
};
});
},
updateSessionServerInfo: (serverId, info) => {
set((prev) => {
const session = prev.sessions[serverId];

View File

@@ -1160,7 +1160,7 @@ export interface CreateSessionAgentStreamReducerQueueInput {
state: (prev: Map<string, TimelineCursor>) => Map<string, TimelineCursor>,
) => void;
setAgents: (serverId: string, state: (prev: Map<string, Agent>) => Map<string, Agent>) => void;
requestCanonicalCatchUp: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
}
function scheduleAgentStreamReducerFlush(callback: () => void): number {
@@ -1174,13 +1174,8 @@ function cancelAgentStreamReducerFlush(id: number) {
export function createSessionAgentStreamReducerQueue(
input: CreateSessionAgentStreamReducerQueueInput,
): AgentStreamReducerQueue {
const {
serverId,
setAgentStreamState,
setAgentTimelineCursor,
setAgents,
requestCanonicalCatchUp,
} = input;
const { serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, recoverTimelineGap } =
input;
return createAgentStreamReducerQueue({
getSnapshot: (agentId) => {
@@ -1258,7 +1253,7 @@ export function createSessionAgentStreamReducerQueue(
handleSideEffects: (agentId, sideEffects) => {
for (const effect of sideEffects) {
if (effect.type === "catch_up") {
requestCanonicalCatchUp(agentId, effect.cursor);
recoverTimelineGap(agentId, effect.cursor);
}
}
},

View File

@@ -4,7 +4,6 @@ import {
isTimelineCatchUpComplete,
planInitialAgentTimelineSync,
planResumeTimelineSync,
planTimelineCatchUpFollowUp,
planTimelineOlderFetch,
} from "./timeline-sync-plan";
@@ -70,31 +69,7 @@ describe("timeline sync planning", () => {
});
});
test("catch-up keeps paging while the daemon reports newer rows", () => {
const plan = planTimelineCatchUpFollowUp({
direction: "after",
hasNewer: true,
endCursor: { epoch: "epoch-1", seq: 200 },
error: null,
});
expect(plan).toEqual({
direction: "after",
cursor: { epoch: "epoch-1", seq: 200 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
});
});
test("catch-up finishes when the daemon reports no newer rows", () => {
const plan = planTimelineCatchUpFollowUp({
direction: "after",
hasNewer: false,
endCursor: { epoch: "epoch-1", seq: 200 },
error: null,
});
expect(plan).toBeNull();
expect(isTimelineCatchUpComplete({ direction: "after", hasNewer: false, error: null })).toBe(
true,
);

View File

@@ -87,19 +87,6 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
} as const;
}
export function planTimelineCatchUpFollowUp(input: {
direction: "tail" | "before" | "after";
hasNewer: boolean;
endCursor: TimelineSyncCursor | null;
error: string | null;
}): ProjectedTimelineAfterFetchPlan | null {
if (input.error || input.direction !== "after" || !input.hasNewer || !input.endCursor) {
return null;
}
return planTimelineCatchUpAfter(input.endCursor);
}
export function isTimelineCatchUpComplete(input: {
direction: "tail" | "before" | "after";
hasNewer: boolean;

View File

@@ -0,0 +1,410 @@
import { expect, test } from "vitest";
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
import { createViewedTimelineSync } from "./viewed-timeline-sync";
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: Error): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
interface MembershipRequest {
agentIds: string[];
succeed(): void;
fail(message: string): void;
}
interface TimelineFetch {
agentId: string;
request: ProjectedTimelineForwardFetchPlan;
respond(input: { hasNewer: boolean; seq?: number }): void;
fail(message: string): void;
}
class TimelineWorld {
readonly errors: string[] = [];
readonly sync = createViewedTimelineSync({
setSubscription: async (agentIds) => {
const result = deferred<void>();
this.memberships.push({
agentIds,
succeed: () => result.resolve(),
fail: (message) => result.reject(new Error(message)),
});
this.releaseMembershipWaiter();
return result.promise;
},
readCursor: (agentId) => this.cursors.get(agentId),
fetchPage: async (agentId, request) => {
const result = deferred<{
hasNewer: boolean;
endCursor: { epoch: string; seq: number } | null;
}>();
this.fetches.push({
agentId,
request,
respond: ({ hasNewer, seq = 1 }) =>
result.resolve({
hasNewer,
endCursor: { epoch: `epoch-${agentId}`, seq },
}),
fail: (message) => result.reject(new Error(message)),
});
this.releaseFetchWaiters();
return result.promise;
},
reportError: (error) => {
this.errors.push(error instanceof Error ? error.message : String(error));
const waiter = this.errorWaiters.shift();
if (waiter) waiter(this.errors.at(-1) ?? "");
},
scheduleRetry: (retry) => {
this.retries.push(retry);
const waiter = this.retryWaiters.shift();
if (waiter) waiter(this.retries.shift()!);
return () => {
const index = this.retries.indexOf(retry);
if (index >= 0) this.retries.splice(index, 1);
};
},
});
private readonly memberships: MembershipRequest[] = [];
private readonly membershipWaiters: Array<(request: MembershipRequest) => void> = [];
private readonly fetches: TimelineFetch[] = [];
private readonly fetchWaiters: Array<{
agentId: string;
resolve(fetch: TimelineFetch): void;
}> = [];
private readonly cursors = new Map<string, { epoch: string; startSeq: number; endSeq: number }>();
private readonly errorWaiters: Array<(message: string) => void> = [];
private readonly retries: Array<() => void> = [];
private readonly retryWaiters: Array<(retry: () => void) => void> = [];
setCursor(agentId: string, endSeq: number): void {
this.cursors.set(agentId, { epoch: `epoch-${agentId}`, startSeq: 1, endSeq });
}
nextMembership(): Promise<MembershipRequest> {
const request = this.memberships.shift();
if (request) return Promise.resolve(request);
return new Promise((resolve) => this.membershipWaiters.push(resolve));
}
nextFetch(agentId: string): Promise<TimelineFetch> {
const index = this.fetches.findIndex((fetch) => fetch.agentId === agentId);
if (index >= 0) return Promise.resolve(this.fetches.splice(index, 1)[0]);
return new Promise((resolve) => this.fetchWaiters.push({ agentId, resolve }));
}
expectNoPendingMembership(): void {
expect(this.memberships).toEqual([]);
}
expectNoPendingFetch(): void {
expect(this.fetches).toEqual([]);
}
nextError(): Promise<string> {
const message = this.errors.at(-1);
if (message) return Promise.resolve(message);
return new Promise((resolve) => this.errorWaiters.push(resolve));
}
nextRetry(): Promise<() => void> {
const retry = this.retries.shift();
if (retry) return Promise.resolve(retry);
return new Promise((resolve) => this.retryWaiters.push(resolve));
}
private releaseMembershipWaiter(): void {
const waiter = this.membershipWaiters.shift();
if (!waiter) return;
const request = this.memberships.shift();
if (request) waiter(request);
}
private releaseFetchWaiters(): void {
for (let waiterIndex = this.fetchWaiters.length - 1; waiterIndex >= 0; waiterIndex -= 1) {
const waiter = this.fetchWaiters[waiterIndex];
const index = this.fetches.findIndex((fetch) => fetch.agentId === waiter.agentId);
if (index < 0) continue;
this.fetchWaiters.splice(waiterIndex, 1);
waiter.resolve(this.fetches.splice(index, 1)[0]);
}
}
}
test("unchanged visible-set publication does not cancel paged catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const firstPage = await world.nextFetch("agent-a");
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]);
firstPage.respond({ hasNewer: true, seq: 5 });
const secondPage = await world.nextFetch("agent-a");
secondPage.respond({ hasNewer: false });
expect(secondPage.request).toEqual({
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 5 },
limit: 100,
projection: "projected",
});
world.expectNoPendingMembership();
});
test("all acknowledged agents begin catch-up independently", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b", "agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const [agentA, agentB] = await Promise.all([
world.nextFetch("agent-a"),
world.nextFetch("agent-b"),
]);
agentA.respond({ hasNewer: false });
agentB.respond({ hasNewer: false });
expect(membership.agentIds).toEqual(["agent-a", "agent-b"]);
});
test("membership changes during acknowledgement never catch up the stale set", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const staleMembership = await world.nextMembership();
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
staleMembership.succeed();
const currentMembership = await world.nextMembership();
currentMembership.succeed();
const agentB = await world.nextFetch("agent-b");
agentB.respond({ hasNewer: false });
expect({ stale: staleMembership.agentIds, current: currentMembership.agentIds }).toEqual({
stale: ["agent-a"],
current: ["agent-b"],
});
world.expectNoPendingFetch();
});
test("removing one agent during paging cancels only that agent", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]);
const initialMembership = await world.nextMembership();
initialMembership.succeed();
const [agentA, agentB] = await Promise.all([
world.nextFetch("agent-a"),
world.nextFetch("agent-b"),
]);
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
const replacement = await world.nextMembership();
agentA.respond({ hasNewer: true, seq: 4 });
agentB.respond({ hasNewer: true, seq: 7 });
const agentBNext = await world.nextFetch("agent-b");
replacement.succeed();
agentBNext.respond({ hasNewer: false });
expect(replacement.agentIds).toEqual(["agent-b"]);
world.expectNoPendingFetch();
});
test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const firstMembership = await world.nextMembership();
firstMembership.succeed();
const stalePage = await world.nextFetch("agent-a");
world.sync.setConnected(false);
stalePage.respond({ hasNewer: true, seq: 8 });
world.sync.setConnected(true);
const restoredMembership = await world.nextMembership();
restoredMembership.succeed();
const restoredPage = await world.nextFetch("agent-a");
restoredPage.respond({ hasNewer: false });
expect(restoredMembership.agentIds).toEqual(["agent-a"]);
world.expectNoPendingFetch();
});
test("overlapping sources deduplicate membership and source removal preserves remaining views", async () => {
const world = new TimelineWorld();
world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]);
world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]);
world.sync.setConnected(true);
const combined = await world.nextMembership();
combined.succeed();
const [agentA, agentB] = await Promise.all([
world.nextFetch("agent-a"),
world.nextFetch("agent-b"),
]);
agentA.respond({ hasNewer: false });
agentB.respond({ hasNewer: false });
world.sync.replaceVisibleAgentIds("left-route", []);
world.expectNoPendingMembership();
world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]);
const remaining = await world.nextMembership();
remaining.succeed();
expect({ combined: combined.agentIds, remaining: remaining.agentIds }).toEqual({
combined: ["agent-a", "agent-b"],
remaining: ["agent-b"],
});
});
test("a failed catch-up reports once and retries through the explicit retry policy", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]);
retryCatchUp();
const retry = await world.nextFetch("agent-a");
retry.respond({ hasNewer: false });
expect({ error, retryDirection: retry.request.direction }).toEqual({
error: "timeline unavailable",
retryDirection: "tail",
});
world.expectNoPendingMembership();
});
test("gap recovery supersedes completed catch-up and pages through the current tail", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const initial = await world.nextFetch("agent-a");
initial.respond({ hasNewer: false });
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
const gapPage = await world.nextFetch("agent-a");
gapPage.respond({ hasNewer: true, seq: 15 });
const finalPage = await world.nextFetch("agent-a");
finalPage.respond({ hasNewer: false });
expect([gapPage.request, finalPage.request]).toEqual([
{
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 10 },
limit: 100,
projection: "projected",
},
{
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 15 },
limit: 100,
projection: "projected",
},
]);
});
test("membership failure autonomously retries without another visibility declaration", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]);
retryMembership();
const retry = await world.nextMembership();
retry.succeed();
const catchUp = await world.nextFetch("agent-a");
catchUp.respond({ hasNewer: false });
expect({ error, failed: failed.agentIds, retry: retry.agentIds }).toEqual({
error: "subscription unavailable",
failed: ["agent-a"],
retry: ["agent-a"],
});
});
test("background sends an empty set and foreground restores visible membership", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const initial = await world.nextMembership();
initial.succeed();
const initialCatchUp = await world.nextFetch("agent-a");
initialCatchUp.respond({ hasNewer: false });
world.sync.setActive(false);
const background = await world.nextMembership();
background.succeed();
world.sync.setActive(true);
const foreground = await world.nextMembership();
foreground.succeed();
const resumedCatchUp = await world.nextFetch("agent-a");
resumedCatchUp.respond({ hasNewer: false });
expect({ background: background.agentIds, foreground: foreground.agentIds }).toEqual({
background: [],
foreground: ["agent-a"],
});
});
test("stale membership retry cannot overwrite a newer effective set", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const staleRetry = await world.nextRetry();
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
const current = await world.nextMembership();
staleRetry();
current.succeed();
const catchUp = await world.nextFetch("agent-b");
catchUp.respond({ hasNewer: false });
expect(current.agentIds).toEqual(["agent-b"]);
world.expectNoPendingMembership();
});
test("membership retry cannot run while disconnected", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const disconnectedRetry = await world.nextRetry();
world.sync.setConnected(false);
disconnectedRetry();
world.expectNoPendingMembership();
world.sync.setConnected(true);
const restored = await world.nextMembership();
restored.succeed();
const catchUp = await world.nextFetch("agent-a");
catchUp.respond({ hasNewer: false });
expect(restored.agentIds).toEqual(["agent-a"]);
});

View File

@@ -0,0 +1,289 @@
import type { AgentTimelineCursorState } from "@/stores/session-store";
import {
planResumeTimelineSync,
planTimelineCatchUpAfter,
type ProjectedTimelineForwardFetchPlan,
} from "./timeline-sync-plan";
interface TimelinePageResult {
hasNewer: boolean;
endCursor: { epoch: string; seq: number } | null;
}
interface ViewedTimelineSyncPorts {
setSubscription(agentIds: string[]): Promise<void>;
readCursor(agentId: string): AgentTimelineCursorState | undefined;
fetchPage(
agentId: string,
request: ProjectedTimelineForwardFetchPlan,
): Promise<TimelinePageResult>;
reportError(error: unknown): void;
scheduleRetry(retry: () => void): () => void;
}
export interface ViewedTimelineUiBridge {
replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void;
}
export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
setActive(active: boolean): void;
setConnected(connected: boolean): void;
recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void;
dispose(): void;
}
type CatchUpStatus = "running" | "complete" | "error";
interface CatchUpState {
generation: number;
status: CatchUpStatus;
cancelRetry?: () => void;
}
function normalizeAgentIds(agentIds: string[]): string[] {
return [...new Set(agentIds)].filter(Boolean).sort();
}
function sameAgentIds(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((agentId, index) => agentId === right[index]);
}
export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): ViewedTimelineSync {
const sources = new Map<string, string[]>();
const catchUps = new Map<string, CatchUpState>();
const catchUpGenerations = new Map<string, number>();
const pendingGaps = new Map<string, ProjectedTimelineForwardFetchPlan>();
let active = true;
let connected = false;
let disposed = false;
let desired: string[] = [];
let acknowledged: string[] = [];
let membershipGeneration = 0;
let reconciling = false;
let reconcileRequested = false;
let membershipNeedsRetry = false;
let cancelMembershipRetry: (() => void) | null = null;
const effectiveAgentIds = () => (active ? normalizeAgentIds([...sources.values()].flat()) : []);
const isAcknowledged = (agentId: string) => acknowledged.includes(agentId);
const isDesired = (agentId: string) => desired.includes(agentId);
const cancelCatchUp = (agentId: string) => {
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
catchUps.get(agentId)?.cancelRetry?.();
catchUps.delete(agentId);
pendingGaps.delete(agentId);
};
const fetchUntilCurrent = async (
agentId: string,
generation: number,
request: ProjectedTimelineForwardFetchPlan,
): Promise<void> => {
if (
disposed ||
!connected ||
!isDesired(agentId) ||
!isAcknowledged(agentId) ||
catchUps.get(agentId)?.generation !== generation
) {
return;
}
try {
const page = await ports.fetchPage(agentId, request);
if (
disposed ||
!connected ||
!isDesired(agentId) ||
!isAcknowledged(agentId) ||
catchUps.get(agentId)?.generation !== generation
) {
return;
}
if (page.hasNewer && page.endCursor) {
await fetchUntilCurrent(agentId, generation, planTimelineCatchUpAfter(page.endCursor));
return;
}
if (page.hasNewer) {
throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`);
}
catchUps.set(agentId, { generation, status: "complete" });
} catch (error) {
if (catchUps.get(agentId)?.generation === generation) {
const cancelRetry = ports.scheduleRetry(() => {
const current = catchUps.get(agentId);
if (current?.generation !== generation || current.status !== "error") return;
startCatchUp(agentId);
});
catchUps.set(agentId, { generation, status: "error", cancelRetry });
ports.reportError(error);
}
}
};
const startCatchUp = (
agentId: string,
options: {
request?: ProjectedTimelineForwardFetchPlan;
supersede?: boolean;
} = {},
) => {
const { request, supersede = false } = options;
if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) {
if (request) pendingGaps.set(agentId, request);
return;
}
const current = catchUps.get(agentId);
if (!supersede && (current?.status === "running" || current?.status === "complete")) {
return;
}
current?.cancelRetry?.();
const generation = (catchUpGenerations.get(agentId) ?? 0) + 1;
catchUpGenerations.set(agentId, generation);
catchUps.set(agentId, { generation, status: "running" });
pendingGaps.delete(agentId);
const nextRequest = request ?? planResumeTimelineSync({ cursor: ports.readCursor(agentId) });
void fetchUntilCurrent(agentId, generation, nextRequest);
};
const startAcknowledgedCatchUps = () => {
for (const agentId of acknowledged) {
const gap = pendingGaps.get(agentId);
startCatchUp(agentId, { request: gap, supersede: Boolean(gap) });
}
};
const reconcileLatestMembership = async (): Promise<void> => {
if (disposed || !connected) return;
const generation = membershipGeneration;
const requested = desired;
if (!membershipNeedsRetry && sameAgentIds(requested, acknowledged)) return;
membershipNeedsRetry = false;
try {
await ports.setSubscription(requested);
} catch (error) {
membershipNeedsRetry = true;
cancelMembershipRetry?.();
cancelMembershipRetry = ports.scheduleRetry(() => {
cancelMembershipRetry = null;
if (
disposed ||
!connected ||
membershipGeneration !== generation ||
!sameAgentIds(desired, requested)
) {
return;
}
void reconcileMembership();
});
ports.reportError(error);
return;
}
cancelMembershipRetry?.();
cancelMembershipRetry = null;
if (disposed || !connected) return;
acknowledged = requested;
if (generation !== membershipGeneration) {
await reconcileLatestMembership();
return;
}
startAcknowledgedCatchUps();
if (!sameAgentIds(desired, acknowledged)) await reconcileLatestMembership();
};
const reconcileMembership = async () => {
if (reconciling) {
reconcileRequested = true;
return;
}
if (disposed || !connected) return;
reconciling = true;
try {
await reconcileLatestMembership();
} finally {
reconciling = false;
if (reconcileRequested && !disposed && connected) {
reconcileRequested = false;
void reconcileMembership();
} else if (
!disposed &&
connected &&
!membershipNeedsRetry &&
!sameAgentIds(desired, acknowledged)
) {
void reconcileMembership();
}
}
};
const retryFailedCatchUps = () => {
for (const agentId of acknowledged) {
if (catchUps.get(agentId)?.status === "error") startCatchUp(agentId);
}
};
const publishEffectiveMembership = () => {
const nextDesired = effectiveAgentIds();
if (sameAgentIds(nextDesired, desired)) {
if (membershipNeedsRetry) void reconcileMembership();
retryFailedCatchUps();
return;
}
for (const agentId of desired) {
if (!nextDesired.includes(agentId)) cancelCatchUp(agentId);
}
cancelMembershipRetry?.();
cancelMembershipRetry = null;
desired = nextDesired;
membershipGeneration += 1;
void reconcileMembership();
};
return {
replaceVisibleAgentIds(sourceId, agentIds) {
const normalized = normalizeAgentIds(agentIds);
if (normalized.length === 0) sources.delete(sourceId);
else sources.set(sourceId, normalized);
publishEffectiveMembership();
},
setActive(nextActive) {
if (active === nextActive) return;
active = nextActive;
publishEffectiveMembership();
},
setConnected(nextConnected) {
if (connected === nextConnected) return;
connected = nextConnected;
if (!connected) {
cancelMembershipRetry?.();
cancelMembershipRetry = null;
acknowledged = [];
membershipGeneration += 1;
for (const agentId of desired) cancelCatchUp(agentId);
return;
}
membershipGeneration += 1;
void reconcileMembership();
},
recoverGap(agentId, cursor) {
if (!isDesired(agentId)) return;
startCatchUp(agentId, {
request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }),
supersede: true,
});
},
dispose() {
disposed = true;
cancelMembershipRetry?.();
cancelMembershipRetry = null;
sources.clear();
membershipGeneration += 1;
for (const agentId of desired) cancelCatchUp(agentId);
desired = [];
acknowledged = [];
},
};
}

View File

@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { Agent } from "@/stores/session-store";
import { reconcileAgentDirectory } from "./agent-directory-reconciliation";
function snapshot(id: string, status: AgentSnapshotPayload["status"]): AgentSnapshotPayload {
return {
id,
provider: "codex",
cwd: "/repo",
model: null,
createdAt: "2026-07-12T10:00:00.000Z",
updatedAt: "2026-07-12T10:00:00.000Z",
lastUserMessageAt: null,
status,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
};
}
function entry(id: string, status: AgentSnapshotPayload["status"]): FetchAgentsEntry {
return {
agent: snapshot(id, status),
project: {
projectKey: "/repo",
projectName: "repo",
checkout: {
cwd: "/repo",
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
function replica(id: string, status: Agent["status"]): Agent {
return {
...snapshot(id, status),
serverId: "server",
createdAt: new Date("2026-07-12T10:00:00.000Z"),
updatedAt: new Date("2026-07-12T10:00:00.000Z"),
lastActivityAt: new Date("2026-07-12T10:00:00.000Z"),
lastUserMessageAt: null,
attentionTimestamp: null,
archivedAt: null,
parentAgentId: null,
};
}
describe("agent directory reconciliation", () => {
it("reports snapshot-only and buffered running transitions exactly once", () => {
const result = reconcileAgentDirectory({
previous: new Map([
["snapshot", replica("snapshot", "running")],
["buffered", replica("buffered", "running")],
]),
snapshot: [entry("snapshot", "idle"), entry("buffered", "running")],
deltas: [
{
kind: "upsert",
agent: snapshot("buffered", "idle"),
project: entry("buffered", "idle").project,
},
{
kind: "upsert",
agent: snapshot("buffered", "idle"),
project: entry("buffered", "idle").project,
},
],
});
expect(result.stoppedRunningAgentIds).toEqual(["snapshot", "buffered"]);
expect(result.entries.map(({ agent }) => [agent.id, agent.status])).toEqual([
["snapshot", "idle"],
["buffered", "idle"],
]);
});
it("preserves ordered upserts and removals received after page one", () => {
const result = reconcileAgentDirectory({
previous: new Map(),
snapshot: [entry("updated", "idle"), entry("removed", "idle")],
deltas: [
{
kind: "upsert",
agent: { ...snapshot("updated", "idle"), title: "live" },
project: entry("updated", "idle").project,
},
{ kind: "remove", agentId: "removed" },
],
});
expect(result.entries.map(({ agent }) => [agent.id, agent.title])).toEqual([
["updated", "live"],
]);
});
it("keeps newer page metadata when a stale buffered upsert arrives", () => {
const result = reconcileAgentDirectory({
previous: new Map([["agent", replica("agent", "running")]]),
snapshot: [
{
...entry("agent", "running"),
agent: {
...snapshot("agent", "running"),
title: "newer page",
updatedAt: "2026-07-12T12:00:00.000Z",
},
},
],
deltas: [
{
kind: "upsert",
agent: {
...snapshot("agent", "idle"),
title: "stale live",
updatedAt: "2026-07-12T11:00:00.000Z",
},
project: entry("agent", "idle").project,
},
],
});
expect({
title: result.entries[0]?.agent.title,
status: result.entries[0]?.agent.status,
stopped: result.stoppedRunningAgentIds,
}).toEqual({ title: "newer page", status: "running", stopped: [] });
});
it("accepts usage from a stale buffered upsert without regressing metadata", () => {
const result = reconcileAgentDirectory({
previous: new Map(),
snapshot: [
{
...entry("agent", "idle"),
agent: {
...snapshot("agent", "idle"),
title: "newer page",
updatedAt: "2026-07-12T12:00:00.000Z",
lastUsage: { inputTokens: 10, outputTokens: 5 },
},
},
],
deltas: [
{
kind: "upsert",
agent: {
...snapshot("agent", "running"),
title: "stale live",
updatedAt: "2026-07-12T11:00:00.000Z",
lastUsage: { inputTokens: 20, outputTokens: 8 },
},
project: entry("agent", "idle").project,
},
],
});
expect({
title: result.entries[0]?.agent.title,
status: result.entries[0]?.agent.status,
usage: result.entries[0]?.agent.lastUsage,
}).toEqual({
title: "newer page",
status: "idle",
usage: { inputTokens: 20, outputTokens: 8 },
});
});
});

View File

@@ -0,0 +1,59 @@
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { Agent } from "@/stores/session-store";
import type { AgentDirectoryDelta } from "./agent-directory-sync";
import { acceptAgentDirectoryUpdate } from "./agent-directory-update-policy";
export function reconcileAgentDirectory(input: {
previous: ReadonlyMap<string, Agent>;
snapshot: FetchAgentsEntry[];
deltas: readonly AgentDirectoryDelta[];
}): { entries: FetchAgentsEntry[]; stoppedRunningAgentIds: string[] } {
const entries = new Map(input.snapshot.map((entry) => [entry.agent.id, entry]));
const statuses = new Map(Array.from(input.previous, ([id, agent]) => [id, agent.status]));
const stoppedRunningAgentIds = new Set<string>();
for (const entry of input.snapshot) {
if (statuses.get(entry.agent.id) === "running" && entry.agent.status !== "running") {
stoppedRunningAgentIds.add(entry.agent.id);
}
statuses.set(entry.agent.id, entry.agent.status);
}
for (const delta of input.deltas) {
if (delta.kind === "remove") {
entries.delete(delta.agentId);
statuses.delete(delta.agentId);
stoppedRunningAgentIds.delete(delta.agentId);
continue;
}
const previousEntry = entries.get(delta.agent.id);
const acceptedAgent = acceptAgentDirectoryUpdate(previousEntry?.agent, delta.agent);
if (statuses.get(delta.agent.id) === "running" && acceptedAgent.status !== "running") {
stoppedRunningAgentIds.add(delta.agent.id);
}
statuses.set(delta.agent.id, acceptedAgent.status);
const previousProject = previousEntry?.project;
entries.set(delta.agent.id, {
agent: acceptedAgent,
project: delta.project ??
previousProject ?? {
projectKey: delta.agent.cwd,
projectName: /[^/]+$/.exec(delta.agent.cwd)?.[0] ?? delta.agent.cwd,
checkout: {
cwd: delta.agent.cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
});
}
return {
entries: Array.from(entries.values()),
stoppedRunningAgentIds: Array.from(stoppedRunningAgentIds),
};
}

View File

@@ -2,8 +2,12 @@ import { describe, expect, it } from "vitest";
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { useSessionStore } from "@/stores/session-store";
import { replaceFetchedAgentDirectory } from "./agent-directory-sync";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync";
function createAgentPayload(
input: Partial<Omit<AgentSnapshotPayload, "labels">> & {
@@ -56,6 +60,10 @@ function createEntry(agent: AgentSnapshotPayload): FetchAgentsEntry {
};
}
function permission(id: string): AgentPermissionRequest {
return { id, provider: "codex", name: id, kind: "tool", title: id };
}
describe("replaceFetchedAgentDirectory", () => {
it("re-derives parentAgentId every time an agent snapshot is ingested", () => {
const serverId = "server-1";
@@ -92,4 +100,139 @@ describe("replaceFetchedAgentDirectory", () => {
store.clearSession(serverId);
});
it("removes every replica-owned artifact for a removed agent", () => {
const serverId = "server-removal";
const agentId = "removed-agent";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const agent = {
...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId),
projectPlacement: null,
};
store.setAgents(serverId, new Map([[agentId, agent]]));
store.setAgentDetails(serverId, new Map([[agentId, agent]]));
store.setQueuedMessages(
serverId,
new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]),
);
store.setAgentTimelineCursor(
serverId,
new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]),
);
store.setPendingPermissions(
serverId,
new Map([["permission", { key: "permission", agentId, request: null as never }]]),
);
setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true });
applyAgentDirectoryDelta({ serverId, delta: { kind: "remove", agentId } });
const session = useSessionStore.getState().sessions[serverId];
expect({
agents: session?.agents.has(agentId),
details: session?.agentDetails.has(agentId),
queued: session?.queuedMessages.has(agentId),
cursor: session?.agentTimelineCursor.has(agentId),
permissions: session?.pendingPermissions.size,
archivePending: isAgentArchiving({ queryClient, serverId, agentId }),
}).toEqual({
agents: false,
details: false,
queued: false,
cursor: false,
permissions: 0,
archivePending: false,
});
store.clearSession(serverId);
});
it("keeps newer metadata while accepting usage-only updates and legacy workspace ownership", () => {
const serverId = "server-usage";
const agentId = "usage-agent";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
store.setWorkspaces(
serverId,
new Map([
[
"legacy-workspace",
{
id: "legacy-workspace",
projectId: "project",
projectDisplayName: "Project",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "worktree",
name: "repo",
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
},
],
]),
);
const current = createAgentPayload({
id: agentId,
title: "current",
status: "running",
updatedAt: "2026-07-12T11:00:00.000Z",
lastUsage: { inputTokens: 10, outputTokens: 5 },
pendingPermissions: [permission("current-permission")],
});
applyAgentDirectoryDelta({
serverId,
delta: { kind: "upsert", agent: current, project: createEntry(current).project },
});
store.flushAgentLastActivity();
setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true });
const staleResult = applyAgentDirectoryDelta({
serverId,
delta: {
kind: "upsert",
agent: {
...current,
title: "stale",
status: "idle",
updatedAt: "2026-07-12T10:00:00.000Z",
lastUsage: { inputTokens: 20, outputTokens: 8 },
pendingPermissions: [permission("stale-permission")],
archivedAt: "2026-07-12T10:00:00.000Z",
},
project: createEntry(current).project,
},
});
store.flushAgentLastActivity();
const state = useSessionStore.getState();
const agent = state.sessions[serverId]?.agents.get(agentId);
expect({
title: agent?.title,
status: agent?.status,
usage: agent?.lastUsage,
workspaceId: agent?.workspaceId,
stoppedRunning: staleResult.stoppedRunning,
permissions: Array.from(state.sessions[serverId]?.pendingPermissions.values() ?? []).map(
({ request }) => request.id,
),
archivePending: isAgentArchiving({ queryClient, serverId, agentId }),
activity: state.agentLastActivity.get(agentId)?.toISOString(),
}).toEqual({
title: "current",
status: "running",
usage: { inputTokens: 20, outputTokens: 8 },
workspaceId: "legacy-workspace",
stoppedRunning: false,
permissions: ["current-permission"],
archivePending: true,
activity: "2026-07-12T11:00:00.000Z",
});
store.clearSession(serverId);
});
});

View File

@@ -2,8 +2,113 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import { type Agent, useSessionStore } from "@/stores/session-store";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy";
type AgentDirectoryFetchEntry = FetchAgentsEntry;
export type AgentDirectoryDelta = Extract<
SessionOutboundMessage,
{ type: "agent_update" }
>["payload"];
export function applyAgentDirectoryDelta(input: { serverId: string; delta: AgentDirectoryDelta }): {
agentId: string;
stoppedRunning: boolean;
} {
if (input.delta.kind === "remove") {
removeAgentDirectoryReplica(input.serverId, input.delta.agentId);
return { agentId: input.delta.agentId, stoppedRunning: false };
}
return upsertAgentDirectoryReplica(input.serverId, input.delta);
}
type AgentUpsertDelta = Extract<AgentDirectoryDelta, { kind: "upsert" }>;
function upsertAgentDirectoryReplica(
serverId: string,
delta: AgentUpsertDelta,
): { agentId: string; stoppedRunning: boolean } {
const normalized = normalizeAgentSnapshot(delta.agent, serverId);
const session = useSessionStore.getState().sessions[serverId];
const previousAgent =
session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id);
const legacyWorkspaceId =
previousAgent?.workspaceId ??
Array.from(session?.workspaces.values() ?? []).find(
(workspace) =>
session?.serverInfo?.features?.workspaceMultiplicity !== true &&
workspace.workspaceDirectory === normalized.cwd,
)?.id;
const agent: Agent = {
...normalized,
workspaceId: normalized.workspaceId ?? legacyWorkspaceId,
projectPlacement:
resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ??
previousAgent?.projectPlacement,
};
const acceptedAgent = upsertAgentReplica(serverId, agent);
if (acceptedAgent.archivedAt) {
clearArchiveAgentPending({ queryClient, serverId, agentId: acceptedAgent.id });
}
replaceAgentPendingPermissions(serverId, acceptedAgent);
useSessionStore.getState().setAgentLastActivity(acceptedAgent.id, acceptedAgent.lastActivityAt);
return {
agentId: acceptedAgent.id,
stoppedRunning: previousAgent?.status === "running" && acceptedAgent.status !== "running",
};
}
function upsertAgentReplica(serverId: string, agent: Agent): Agent {
let acceptedAgent = agent;
useSessionStore.getState().setAgents(serverId, (current) => {
const currentAgent = current.get(agent.id);
acceptedAgent = acceptAgentDirectoryUpdate(currentAgent, agent);
if (acceptedAgent === currentAgent) return current;
const next = new Map(current);
next.set(agent.id, acceptedAgent);
return next;
});
return acceptedAgent;
}
function replaceAgentPendingPermissions(serverId: string, agent: Agent): void {
const pendingPermissions = new Map(
useSessionStore.getState().sessions[serverId]?.pendingPermissions,
);
for (const [key, pending] of pendingPermissions) {
if (pending.agentId === agent.id) pendingPermissions.delete(key);
}
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
pendingPermissions.set(key, { key, agentId: agent.id, request });
}
useSessionStore.getState().setPendingPermissions(serverId, pendingPermissions);
}
function removeAgentDirectoryReplica(serverId: string, agentId: string): void {
const store = useSessionStore.getState();
clearArchiveAgentPending({ queryClient, serverId, agentId });
const removeKey = <T>(current: Map<string, T>): Map<string, T> => {
if (!current.has(agentId)) return current;
const next = new Map(current);
next.delete(agentId);
return next;
};
store.setAgents(serverId, removeKey);
store.setAgentDetails(serverId, removeKey);
store.setQueuedMessages(serverId, removeKey);
store.setAgentTimelineCursor(serverId, removeKey);
store.setPendingPermissions(serverId, (current) => {
const next = new Map(current);
for (const [key, pending] of next) {
if (pending.agentId === agentId) next.delete(key);
}
return next.size === current.size ? current : next;
});
store.setAgentAuthoritativeHistoryApplied(serverId, agentId, false);
}
interface PendingPermissionEntry {
key: string;
@@ -49,6 +154,12 @@ export function replaceFetchedAgentDirectory(input: {
const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input);
const store = useSessionStore.getState();
for (const agent of fetchedAgents.values()) {
if (agent.archivedAt) {
clearArchiveAgentPending({ queryClient, serverId: input.serverId, agentId: agent.id });
}
}
store.setAgents(input.serverId, fetchedAgents);
store.setAgentDetails(input.serverId, (prev) => {
let next: Map<string, Agent> | null = null;

View File

@@ -0,0 +1,20 @@
import equal from "fast-deep-equal";
import type { AgentUsage } from "@getpaseo/protocol/agent-types";
interface AgentUpdateValue {
updatedAt: Date | string;
lastUsage?: AgentUsage;
}
function timestamp(value: Date | string): number {
return value instanceof Date ? value.getTime() : Date.parse(value);
}
export function acceptAgentDirectoryUpdate<T extends AgentUpdateValue>(
current: T | undefined,
incoming: T,
): T {
if (!current || timestamp(incoming.updatedAt) >= timestamp(current.updatedAt)) return incoming;
if (equal(incoming.lastUsage, current.lastUsage)) return current;
return { ...current, lastUsage: incoming.lastUsage };
}

View File

@@ -121,7 +121,7 @@ export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty(
return true;
}
async function readLegacyDaemonWorkspaceDirectory(input: {
export async function readLegacyDaemonWorkspaceDirectory(input: {
client: Pick<DaemonClient, "fetchAgents">;
subscribe?: FetchAgentsOptions["subscribe"];
page?: FetchAgentsOptions["page"];
@@ -193,7 +193,7 @@ export function applyLegacyDaemonWorkspaceOwnership(input: {
};
}
function replaceLegacyDaemonWorkspaceDirectory(input: {
export function replaceLegacyDaemonWorkspaceDirectory(input: {
serverId: string;
entries: FetchAgentsEntry[];
}): LegacyDaemonWorkspaceSnapshot {
@@ -242,7 +242,7 @@ function readFetchAgentsNextCursor(
return null;
}
function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[] {
export function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[] {
return entries.map((entry) => {
const workspaceId = resolveLegacyWorkspaceId(entry);
return {

View File

@@ -76,7 +76,7 @@ function createMockTransport() {
return {
transport,
sent,
triggerOpen: (options?: { preserveSent?: boolean }) => {
triggerOpen: (options?: { preserveSent?: boolean; features?: Record<string, boolean> }) => {
onOpen();
if (!options?.preserveSent) {
// Ignore HELLO handshake payloads in assertions.
@@ -92,6 +92,7 @@ function createMockTransport() {
serverId: `srv_test_${serverInfoOrdinal++}`,
hostname: null,
version: null,
...(options?.features ? { features: options.features } : {}),
},
},
}),
@@ -219,6 +220,95 @@ test("advertises consumer-provided browser automation capabilities", async () =>
});
});
test("sets the complete viewed timeline subscription only when the daemon supports it", async () => {
const supportedTransport = createMockTransport();
const supportedClient = new DaemonClient({
url: "ws://test",
clientId: "timeline_supported",
transportFactory: () => supportedTransport.transport,
reconnect: { enabled: false },
});
const legacyTransport = createMockTransport();
const legacyClient = new DaemonClient({
url: "ws://test",
clientId: "timeline_legacy",
transportFactory: () => legacyTransport.transport,
reconnect: { enabled: false },
});
clients.push(supportedClient, legacyClient);
const supportedConnect = supportedClient.connect();
supportedTransport.triggerOpen({ features: { selectiveAgentTimeline: true } });
await supportedConnect;
const legacyConnect = legacyClient.connect();
legacyTransport.triggerOpen();
await legacyConnect;
expect(supportedClient.getLastServerInfoMessage()?.features).toEqual({
selectiveAgentTimeline: true,
});
const setPromise = supportedClient.setAgentTimelineSubscription(["agent-b", "agent-a"]);
await Promise.resolve();
const request = parseSentFrame(supportedTransport.sent[0]);
supportedTransport.triggerMessage(
wrapSessionMessage({
type: "agent.timeline.set_subscription.response",
payload: {
requestId: request.requestId,
agentIds: ["agent-a", "agent-b"],
},
}),
);
await setPromise;
await legacyClient.setAgentTimelineSubscription(["agent-a"]);
expect({ request, legacyFrames: legacyTransport.sent }).toEqual({
request: {
type: "agent.timeline.set_subscription.request",
requestId: expect.any(String),
agentIds: ["agent-a", "agent-b"],
},
legacyFrames: [],
});
});
test("normalizes legacy and dedicated agent attention notifications", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "attention_normalization",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connect = client.connect();
mock.triggerOpen();
await connect;
const notifications: unknown[] = [];
client.onAgentAttentionRequired((notification) => notifications.push(notification));
const payload = {
agentId: "agent-a",
reason: "finished",
timestamp: "2026-07-12T00:00:00.000Z",
shouldNotify: true,
} as const;
mock.triggerMessage(
wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: payload.agentId,
timestamp: payload.timestamp,
event: { type: "attention_required", provider: "codex", ...payload },
},
}),
);
mock.triggerMessage(wrapSessionMessage({ type: "agent_attention_required", payload }));
expect(notifications).toEqual([payload, payload]);
});
const noopLogger: Logger = {
debug: () => {},
info: () => {},
@@ -557,6 +647,7 @@ test("advertises client capabilities in hello", async () => {
custom_mode_icons: true,
provider_subagents: true,
reasoning_merge_enum: true,
selective_agent_timeline: true,
terminal_reflowable_snapshot: true,
browser_host: {
supportedCommands: ["list_tabs"],

View File

@@ -1,5 +1,6 @@
import type { z } from "zod";
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
import {
AgentCreateFailedStatusPayloadSchema,
AgentCreatedStatusPayloadSchema,
@@ -317,6 +318,14 @@ export interface SendMessageOptions {
attachments?: SendAgentMessageRequest["attachments"];
}
export interface AgentAttentionRequiredNotification {
agentId: string;
reason: "finished" | "error" | "permission";
timestamp: string;
shouldNotify: boolean;
notification?: AgentAttentionNotificationPayload;
}
type AgentConfigOverrides = Partial<Omit<AgentSessionConfig, "provider" | "cwd">>;
export interface CreateAgentRequestOptions extends AgentConfigOverrides {
@@ -1478,6 +1487,31 @@ export class DaemonClient {
};
}
onAgentAttentionRequired(
handler: (notification: AgentAttentionRequiredNotification) => void,
): () => void {
const unsubscribeLegacy = this.on("agent_stream", (message) => {
if (message.payload.event.type !== "attention_required") {
return;
}
const event = message.payload.event;
handler({
agentId: message.payload.agentId,
reason: event.reason,
timestamp: event.timestamp,
shouldNotify: event.shouldNotify,
...(event.notification ? { notification: event.notification } : {}),
});
});
const unsubscribeDedicated = this.on("agent_attention_required", (message) => {
handler(message.payload);
});
return () => {
unsubscribeLegacy();
unsubscribeDedicated();
};
}
// ============================================================================
// Core Send Helpers
// ============================================================================
@@ -2687,6 +2721,35 @@ export class DaemonClient {
return payload;
}
async setAgentTimelineSubscription(agentIds: string[]): Promise<void> {
// COMPAT(selectiveAgentTimeline): added in v0.1.106. Old daemons keep their
// legacy global stream and do not understand this RPC. Remove after
// 2027-01-12 once the supported daemon floor is >= v0.1.106.
if (!this.lastServerInfoMessage?.features?.selectiveAgentTimeline) {
return;
}
const requestId = this.createRequestId();
const normalizedAgentIds = [...new Set(agentIds)].sort();
const message = SessionInboundMessageSchema.parse({
type: "agent.timeline.set_subscription.request",
agentIds: normalizedAgentIds,
requestId,
});
await this.sendRequest({
requestId,
message,
options: { skipQueue: true },
select: (response) => {
if (response.type !== "agent.timeline.set_subscription.response") {
return null;
}
return response.payload.requestId === requestId ? response.payload : null;
},
});
}
async buildAgentForkContext(
agentId: string,
options: AgentForkContextOptions = {},
@@ -5045,6 +5108,7 @@ export class DaemonClient {
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.customModeIcons]: true,
[CLIENT_CAPS.selectiveAgentTimeline]: true,
[CLIENT_CAPS.reasoningMergeEnum]: true,
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
[CLIENT_CAPS.providerSubagents]: true,

View File

@@ -1,4 +1,8 @@
export const CLIENT_CAPS = {
// COMPAT(selectiveAgentTimeline): added in v0.1.106. Capable clients receive
// agent streams only for their explicit viewed set. Remove after 2027-01-12
// once the supported client floor is >= v0.1.106.
selectiveAgentTimeline: "selective_agent_timeline",
reasoningMergeEnum: "reasoning_merge_enum",
// COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to
// a closed enum and crash rendering unknown values; daemon downgrades icons

View File

@@ -437,3 +437,35 @@ describe("daemon update messages", () => {
});
});
});
describe("viewed timeline subscription messages", () => {
test("parses a complete viewed-agent set and its acknowledgement", () => {
const request = SessionInboundMessageSchema.parse({
type: "agent.timeline.set_subscription.request",
agentIds: ["agent-a", "agent-b"],
requestId: "timeline-subscription-1",
});
const response = SessionOutboundMessageSchema.parse({
type: "agent.timeline.set_subscription.response",
payload: {
agentIds: ["agent-a", "agent-b"],
requestId: "timeline-subscription-1",
},
});
expect({ request, response }).toEqual({
request: {
type: "agent.timeline.set_subscription.request",
agentIds: ["agent-a", "agent-b"],
requestId: "timeline-subscription-1",
},
response: {
type: "agent.timeline.set_subscription.response",
payload: {
agentIds: ["agent-a", "agent-b"],
requestId: "timeline-subscription-1",
},
},
});
});
});

View File

@@ -1369,6 +1369,12 @@ export const ProviderSubagentTimelineRequestMessageSchema = z.object({
limit: z.number().int().nonnegative().optional(),
});
export const SetAgentTimelineSubscriptionRequestMessageSchema = z.object({
type: z.literal("agent.timeline.set_subscription.request"),
agentIds: z.array(z.string()),
requestId: z.string(),
});
export const AgentForkContextRequestMessageSchema = z.object({
type: z.literal("agent.fork_context.request"),
agentId: z.string(),
@@ -2359,6 +2365,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
FetchAgentTimelineRequestMessageSchema,
ProviderSubagentListRequestMessageSchema,
ProviderSubagentTimelineRequestMessageSchema,
SetAgentTimelineSubscriptionRequestMessageSchema,
AgentForkContextRequestMessageSchema,
SetAgentModeRequestMessageSchema,
SetAgentModelRequestMessageSchema,
@@ -2678,6 +2685,8 @@ export const ServerInfoStatusPayloadSchema = z
// Daemon advertises pluggable non-GitHub forge support (the forge registry);
// the client gates non-GitHub setup UI on it.
forgeProviders: z.boolean().optional(),
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
selectiveAgentTimeline: z.boolean().optional(),
})
.optional(),
})
@@ -3425,6 +3434,35 @@ export const ProviderSubagentUpdateMessageSchema = z.object({
]),
});
export const SetAgentTimelineSubscriptionResponseMessageSchema = z.object({
type: z.literal("agent.timeline.set_subscription.response"),
payload: z.object({
agentIds: z.array(z.string()),
requestId: z.string(),
}),
});
export const AgentAttentionRequiredMessageSchema = z.object({
type: z.literal("agent_attention_required"),
payload: z.object({
agentId: z.string(),
reason: z.enum(["finished", "error", "permission"]),
timestamp: z.string(),
shouldNotify: z.boolean(),
notification: z
.object({
title: z.string(),
body: z.string(),
data: z.object({
serverId: z.string(),
agentId: z.string(),
reason: z.enum(["finished", "error", "permission"]),
}),
})
.optional(),
}),
});
export const AgentForkContextResponseMessageSchema = z.object({
type: z.literal("agent.fork_context.response"),
payload: z.object({
@@ -4832,6 +4870,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProviderSubagentListResponseMessageSchema,
ProviderSubagentTimelineResponseMessageSchema,
ProviderSubagentUpdateMessageSchema,
SetAgentTimelineSubscriptionResponseMessageSchema,
AgentAttentionRequiredMessageSchema,
AgentForkContextResponseMessageSchema,
CancelAgentResponseMessageSchema,
ClearAgentAttentionResponseMessageSchema,
@@ -5365,6 +5405,7 @@ export const WSHelloMessageSchema = z.object({
voice: z.boolean().optional(),
pushNotifications: z.boolean().optional(),
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.selectiveAgentTimeline]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
[CLIENT_CAPS.providerSubagents]: z.boolean().optional(),

View File

@@ -0,0 +1,273 @@
import { afterEach, beforeEach, expect, test } from "vitest";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { DaemonClient } from "./test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js";
interface MessageWaiter {
predicate(message: SessionOutboundMessage): boolean;
resolve(message: SessionOutboundMessage): void;
reject(error: Error): void;
timeout: ReturnType<typeof setTimeout>;
}
class ConnectedClient {
readonly messages: SessionOutboundMessage[] = [];
private readonly waiters: MessageWaiter[] = [];
private readonly unsubscribe: () => void;
constructor(readonly client: DaemonClient) {
this.unsubscribe = client.subscribeRawMessages((message) => {
this.messages.push(message);
for (let waiterIndex = this.waiters.length - 1; waiterIndex >= 0; waiterIndex -= 1) {
const waiter = this.waiters[waiterIndex];
if (!waiter.predicate(message)) continue;
clearTimeout(waiter.timeout);
this.waiters.splice(waiterIndex, 1);
waiter.resolve(message);
}
});
}
clear(): void {
this.messages.length = 0;
}
next(
predicate: (message: SessionOutboundMessage) => boolean,
description: string,
): Promise<SessionOutboundMessage> {
const existing = this.messages.find(predicate);
if (existing) return Promise.resolve(existing);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
const index = this.waiters.findIndex((waiter) => waiter.resolve === resolve);
if (index >= 0) this.waiters.splice(index, 1);
reject(new Error(`Timed out waiting for ${description}`));
}, 5_000);
this.waiters.push({ predicate, resolve, reject, timeout });
});
}
hasTimeline(agentId: string): boolean {
return this.messages.some(
(message) => message.type === "agent_stream" && message.payload.agentId === agentId,
);
}
async barrier(label: string): Promise<void> {
await this.client.ping({ requestId: `barrier-${label}` });
}
close(): void {
this.unsubscribe();
for (const waiter of this.waiters) {
clearTimeout(waiter.timeout);
waiter.reject(new Error("Client boundary closed"));
}
this.waiters.length = 0;
}
}
function isAgentStream(agentId: string) {
return (message: SessionOutboundMessage): boolean =>
message.type === "agent_stream" && message.payload.agentId === agentId;
}
function isDedicatedAttention(agentId: string) {
return (message: SessionOutboundMessage): boolean =>
message.type === "agent_attention_required" && message.payload.agentId === agentId;
}
function isLegacyAttention(agentId: string) {
return (message: SessionOutboundMessage): boolean =>
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "attention_required";
}
function dedicatedAttentionResult(message: SessionOutboundMessage, timelineLeaked: boolean) {
if (message.type !== "agent_attention_required") {
throw new Error(`Expected agent_attention_required, received ${message.type}`);
}
return {
type: message.type,
shouldNotify: message.payload.shouldNotify,
timelineLeaked,
};
}
function legacyAttentionResult(message: SessionOutboundMessage) {
if (message.type !== "agent_stream" || message.payload.event.type !== "attention_required") {
throw new Error(`Expected legacy attention_required agent_stream, received ${message.type}`);
}
return {
type: message.type,
eventType: message.payload.event.type,
agentId: message.payload.agentId,
};
}
let daemon: TestPaseoDaemon;
const clients: ConnectedClient[] = [];
beforeEach(async () => {
daemon = await createTestPaseoDaemon();
});
afterEach(async () => {
for (const connected of clients) {
connected.close();
await connected.client.close().catch(() => undefined);
}
clients.length = 0;
await daemon.close();
}, 30_000);
async function connect(input: { clientId: string; selective: boolean }): Promise<ConnectedClient> {
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
clientId: input.clientId,
capabilities: { [CLIENT_CAPS.selectiveAgentTimeline]: input.selective },
reconnect: { enabled: false },
});
await client.connect();
const connected = new ConnectedClient(client);
clients.push(connected);
return connected;
}
test("real WebSocket sessions enforce selective delivery, retained resets, downgrade, and dedicated attention", async () => {
const legacy = await connect({ clientId: "legacy-client", selective: false });
let capable = await connect({ clientId: "capable-client", selective: true });
const agents = await Promise.all(
["A", "B", "C"].map((title) =>
legacy.client.createAgent({
provider: "codex",
cwd: "/tmp",
title: `Selective ${title}`,
modeId: "full-access",
}),
),
);
const [agentA, agentB, agentC] = agents;
legacy.clear();
capable.clear();
await daemon.daemon.agentManager.emitLiveTimelineItem(agentC.id, {
type: "assistant_message",
text: "before membership",
});
await legacy.next(isAgentStream(agentC.id), "legacy global delivery before membership");
await capable.barrier("before-membership");
expect(capable.hasTimeline(agentC.id)).toBe(false);
await capable.client.setAgentTimelineSubscription([agentA.id, agentB.id]);
legacy.clear();
capable.clear();
await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, {
type: "assistant_message",
text: "viewed A",
});
await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, {
type: "assistant_message",
text: "viewed B",
});
await daemon.daemon.agentManager.emitLiveTimelineItem(agentC.id, {
type: "assistant_message",
text: "unviewed C",
});
await Promise.all([
capable.next(isAgentStream(agentA.id), "capable A delivery"),
capable.next(isAgentStream(agentB.id), "capable B delivery"),
legacy.next(isAgentStream(agentC.id), "legacy C delivery"),
]);
await capable.barrier("unviewed-c");
expect(capable.hasTimeline(agentC.id)).toBe(false);
await capable.client.setAgentTimelineSubscription([agentB.id]);
legacy.clear();
capable.clear();
await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, {
type: "assistant_message",
text: "removed A",
});
await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, {
type: "assistant_message",
text: "retained B",
});
await Promise.all([
legacy.next(isAgentStream(agentA.id), "legacy removed A delivery"),
capable.next(isAgentStream(agentB.id), "capable retained B delivery"),
]);
await capable.barrier("removed-a");
expect(capable.hasTimeline(agentA.id)).toBe(false);
capable.close();
await capable.client.close();
clients.splice(clients.indexOf(capable), 1);
capable = await connect({ clientId: "capable-client", selective: true });
legacy.clear();
capable.clear();
await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, {
type: "assistant_message",
text: "after capable resume",
});
await legacy.next(isAgentStream(agentB.id), "legacy delivery after capable resume");
await capable.barrier("resumed-membership-reset");
expect(capable.hasTimeline(agentB.id)).toBe(false);
capable.client.sendHeartbeat({
deviceType: "mobile",
focusedAgentId: null,
lastActivityAt: new Date().toISOString(),
appVisible: true,
});
legacy.clear();
capable.clear();
const attention = capable.next(
isDedicatedAttention(agentC.id),
"capable dedicated attention notification",
);
const legacyAttention = legacy.next(
isLegacyAttention(agentC.id),
"legacy attention stream notification",
);
await legacy.client.sendMessage(agentC.id, "finish attention boundary test");
const [attentionMessage, legacyAttentionMessage] = await Promise.all([
attention,
legacyAttention,
]);
await capable.barrier("attention-delivery");
expect({
capable: dedicatedAttentionResult(attentionMessage, capable.hasTimeline(agentC.id)),
legacy: legacyAttentionResult(legacyAttentionMessage),
}).toEqual({
capable: {
type: "agent_attention_required",
shouldNotify: true,
timelineLeaked: false,
},
legacy: {
type: "agent_stream",
eventType: "attention_required",
agentId: agentC.id,
},
});
capable.close();
await capable.client.close();
clients.splice(clients.indexOf(capable), 1);
const downgraded = await connect({ clientId: "capable-client", selective: false });
downgraded.clear();
await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, {
type: "assistant_message",
text: "after downgrade",
});
const downgradedDelivery = await downgraded.next(
isAgentStream(agentA.id),
"legacy global delivery after capability downgrade",
);
expect(downgradedDelivery.type).toBe("agent_stream");
}, 30_000);

View File

@@ -4554,6 +4554,28 @@ describe("chat/schedule/loop dispatch routing (behavior preservation)", () => {
});
});
test("replaces a capable session's complete viewed timeline set", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({ messages });
session.updateClientCapabilities({ selective_agent_timeline: true });
await session.handleMessage({
type: "agent.timeline.set_subscription.request",
agentIds: ["agent-b", "agent-a", "agent-a"],
requestId: "timeline-subscription-1",
});
expect(messages).toEqual([
{
type: "agent.timeline.set_subscription.response",
payload: {
agentIds: ["agent-a", "agent-b"],
requestId: "timeline-subscription-1",
},
},
]);
});
describe("agent config setters", () => {
test("set_agent_mode_request: success emits accepted response carrying the notice", async () => {
const messages: SessionOutboundMessage[] = [];

View File

@@ -583,6 +583,7 @@ export class Session {
private readonly daemonConfigStore: DaemonConfigStore;
private readonly pushTokenStore: PushTokenStore;
private unsubscribeAgentEvents: (() => void) | null = null;
private viewedTimelineAgentIds = new Set<string>();
private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null;
private readonly agentUpdates: AgentUpdatesService;
private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null;
@@ -965,6 +966,9 @@ export class Session {
updateClientCapabilities(capabilities: Record<string, unknown> | null): void {
this.clientCapabilities = parseClientCapabilities(capabilities);
if (this.supports(CLIENT_CAPS.selectiveAgentTimeline)) {
this.viewedTimelineAgentIds.clear();
}
}
supports(capability: ClientCapability): boolean {
@@ -979,6 +983,16 @@ export class Session {
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
}
private async emitCreatedWorkspaceUpdate(workspace: WorkspaceDescriptorPayload): Promise<void> {
if (this.workspaceUpdatesSubscription) {
await this.emitWorkspaceUpdateForWorkspaceId(workspace.id);
return;
}
// COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12.
// Older clients create before subscribing and require the causal update beside the response.
this.emit({ type: "workspace_update", payload: { kind: "upsert", workspace } });
}
async archiveWorkspaceRecordForExternalMutation(workspaceId: string): Promise<void> {
await this.archiveWorkspaceRecord(workspaceId);
}
@@ -1271,10 +1285,31 @@ export class Session {
"agent.session.forward_stream",
);
this.emit({
type: "agent_stream",
payload: this.buildAgentStreamPayload(event, serializedEvent),
});
if (
this.supports(CLIENT_CAPS.selectiveAgentTimeline) &&
serializedEvent.type === "attention_required"
) {
this.emit({
type: "agent_attention_required",
payload: {
agentId: event.agentId,
reason: serializedEvent.reason,
timestamp: serializedEvent.timestamp,
shouldNotify: serializedEvent.shouldNotify,
...(serializedEvent.notification
? { notification: serializedEvent.notification }
: {}),
},
});
} else if (
!this.supports(CLIENT_CAPS.selectiveAgentTimeline) ||
this.viewedTimelineAgentIds.has(event.agentId)
) {
this.emit({
type: "agent_stream",
payload: this.buildAgentStreamPayload(event, serializedEvent),
});
}
if (event.event.type === "permission_requested") {
this.emit({
@@ -1524,6 +1559,17 @@ export class Session {
return this.handleProviderSubagentListRequest(msg);
case "agent.provider_subagents.timeline.get.request":
return this.handleProviderSubagentTimelineRequest(msg);
case "agent.timeline.set_subscription.request": {
const agentIds = [...new Set(msg.agentIds)].sort();
if (this.supports(CLIENT_CAPS.selectiveAgentTimeline)) {
this.viewedTimelineAgentIds = new Set(agentIds);
}
this.emit({
type: "agent.timeline.set_subscription.response",
payload: { agentIds, requestId: msg.requestId },
});
return undefined;
}
case "agent.fork_context.request":
return this.handleAgentForkContextRequest(msg);
default:
@@ -4259,6 +4305,9 @@ export class Session {
this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace);
if (!nextWorkspace) {
if (workspace && !subscription.lastEmittedByWorkspaceId.has(workspaceId)) {
continue;
}
subscription.lastEmittedByWorkspaceId.delete(workspaceId);
this.bufferOrEmitWorkspaceUpdate(
subscription,
@@ -4661,7 +4710,7 @@ export class Session {
error: null,
},
});
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
await this.emitCreatedWorkspaceUpdate(descriptor);
void this.workspaceGitService
.getSnapshot(workspace.cwd, { force: true, includeForge: true, reason: "open_project" })
.catch((error) => {
@@ -4746,10 +4795,7 @@ export class Session {
error: null,
},
});
this.emit({
type: "workspace_update",
payload: { kind: "upsert", workspace: descriptor },
});
await this.emitCreatedWorkspaceUpdate(descriptor);
}
private async resolveWorktreeSourceCwd(input: {

View File

@@ -7827,4 +7827,75 @@ test("workspace.create.response persists the first prompt as the initial title",
expect(workspaceId).toBeDefined();
const persisted = await session.workspaceRegistry.get(workspaceId as string);
expect(persisted?.title).toBe("Add retries to the payments flow");
expect(filterByType(emitted, "workspace_update")).toHaveLength(1);
});
test("workspace create emits through a matching workspace subscription", async () => {
const emitted: SessionOutboundMessage[] = [];
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const session = createSessionForWorkspaceTests({
onMessage: (message) => emitted.push(message),
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
upsert: async (workspace) => {
workspaces.set(workspace.workspaceId, workspace);
},
archive: async () => {},
remove: async () => {},
},
});
session.listAgentPayloads = async () => [];
await session.handleMessage({
type: "fetch_workspaces_request",
requestId: "req-subscribe-create-match",
filter: { query: "repo" },
subscribe: { subscriptionId: "sub-create-match" },
});
emitted.length = 0;
await session.handleMessage({
type: "workspace.create.request",
requestId: "req-create-match",
source: { kind: "directory", path: REPO_CWD },
});
expect(filterByType(emitted, "workspace_update")).toHaveLength(1);
});
test("workspace create stays out of a non-matching workspace subscription", async () => {
const emitted: SessionOutboundMessage[] = [];
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const session = createSessionForWorkspaceTests({
onMessage: (message) => emitted.push(message),
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
upsert: async (workspace) => {
workspaces.set(workspace.workspaceId, workspace);
},
archive: async () => {},
remove: async () => {},
},
});
session.listAgentPayloads = async () => [];
await session.handleMessage({
type: "fetch_workspaces_request",
requestId: "req-subscribe-create-filtered",
filter: { query: "definitely-not-this-workspace" },
subscribe: { subscriptionId: "sub-create-filtered" },
});
emitted.length = 0;
await session.handleMessage({
type: "workspace.create.request",
requestId: "req-create-filtered",
source: { kind: "directory", path: REPO_CWD },
});
expect(filterByType(emitted, "workspace_update")).toEqual([]);
});

View File

@@ -173,6 +173,7 @@ function createSessionWithActivity(
) {
return {
getClientActivity: vi.fn(() => activity),
supports: () => false,
};
}

View File

@@ -1163,12 +1163,15 @@ export class VoiceAssistantWebSocketServer {
existing.session.updateAppVersion(newAppVersion);
}
const newClientCapabilities = message.capabilities ?? null;
// COMPAT(selectiveAgentTimeline): added in v0.1.106. Every capable resumed
// hello resets membership before server_info so stale retained-session
// state cannot leak. Remove after 2027-01-12.
existing.session.updateClientCapabilities(newClientCapabilities);
if (
JSON.stringify(existing.clientCapabilities ?? null) !==
JSON.stringify(newClientCapabilities ?? null)
) {
existing.clientCapabilities = newClientCapabilities;
existing.session.updateClientCapabilities(newClientCapabilities);
this.syncBrowserToolsClientRegistration(existing);
}
existing.sockets.add(ws);
@@ -1281,6 +1284,8 @@ export class VoiceAssistantWebSocketServer {
importSessionWorkspaceTarget: true,
// COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106.
forgeProviders: true,
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
selectiveAgentTimeline: true,
},
};
}
@@ -2009,21 +2014,36 @@ export class VoiceAssistantWebSocketServer {
for (const [clientIndex, { ws }] of clientEntries.entries()) {
const shouldNotify = clientIndex === plan.inAppRecipientIndex;
const timestamp = new Date().toISOString();
const message = wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: params.agentId,
event: {
type: "attention_required",
provider: params.provider,
reason: params.reason,
timestamp,
shouldNotify,
notification,
},
timestamp,
},
});
const connection = this.sessions.get(ws);
const attentionPayload = {
agentId: params.agentId,
reason: params.reason,
timestamp,
shouldNotify,
notification,
};
const message = wrapSessionMessage(
connection?.session.supports(CLIENT_CAPS.selectiveAgentTimeline)
? {
type: "agent_attention_required",
payload: attentionPayload,
}
: {
type: "agent_stream",
payload: {
agentId: params.agentId,
event: {
type: "attention_required",
provider: params.provider,
reason: params.reason,
timestamp,
shouldNotify,
notification,
},
timestamp,
},
},
);
this.sendToClient(ws, message);
}