Reduce workspace, agent, and chat sync traffic (#2028)

* 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.

* fix(sync): preserve selective delivery boundaries

Keep selective timeline capability app-owned, union viewed sets across shared sockets, and reconcile directory side effects from accepted state. Visibility and archive suppression now follow their existing authoritative boundaries.

* test(app): align browser fixtures with runtime contracts

* fix(sync): preserve mixed-client delivery guarantees

* fix(sync): finish paged history before advancing

* fix(sync): replay live deltas after refresh failures

* test(server): model socket capability lookup

* test(app): use platform shortcut for split-pane coverage

* fix(app): keep hidden timelines dormant

* test(app): normalize split-pane shortcut setup

* fix(app): preserve sync state across retries

* fix(app): preserve background sync state across races

* fix directory bootstrap reconciliation edge cases

* fix selective timeline compatibility races

* fix superseded directory sync races

* fix(sync): centralize directory replica ordering

Own agent and workspace refresh transactions in HostRuntime so reconnect epochs, buffered updates, and lifecycle invalidation share one ordering boundary. Route timeline metadata and mixed-capability stream delivery through their authoritative runtime sources.

* fix(app): keep timeline requests runtime-scoped

* fix(app): close directory bootstrap races
This commit is contained in:
Mohamed Boudra
2026-07-17 17:03:34 +02:00
committed by GitHub
parent 6c99efae52
commit 1977d330ed
57 changed files with 5783 additions and 1343 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,148 @@
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 enableMoveTabShortcut(page: Page) {
await page.addInitScript(() => {
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
});
}
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 enableMoveTabShortcut(page);
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 }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Second viewed chat", exact: true }),
).toBeVisible();
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

@@ -20,7 +20,7 @@ import {
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
const LEGACY_AGENT_ID = "legacy-cwd-only-agent";
const LEGACY_AGENT_ID = "10000000-0000-4000-8000-000000000001";
const SERVER_ID = `srv_restart_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
interface RestartDaemonClient {

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

@@ -8,6 +8,7 @@ import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { clearOptimisticUserMessages } from "@/types/stream";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
interface UseRewindAgentMutationInput {
serverId?: string;
@@ -47,7 +48,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
.getState()
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
: undefined;
await input.client.fetchAgentTimeline(input.agentId, {
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
direction: "tail",
projection: "projected",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
revalidateSessionAfterResume,
SESSION_STALE_AFTER_MS,
} from "./session-resume-revalidation";
describe("session resume revalidation", () => {
it("refreshes both directories and timeline history after a stale resume", async () => {
const calls: string[] = [];
const revalidated = await revalidateSessionAfterResume({
awayMs: SESSION_STALE_AFTER_MS,
serverId: "server",
bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`),
refreshDirectories: async () => calls.push("directories"),
});
expect(revalidated).toBe(true);
expect(calls).toEqual(["history:server", "directories"]);
});
it("does nothing after a brief background interval", async () => {
const calls: string[] = [];
const revalidated = await revalidateSessionAfterResume({
awayMs: SESSION_STALE_AFTER_MS - 1,
serverId: "server",
bumpHistorySyncGeneration: () => calls.push("history"),
refreshDirectories: async () => calls.push("directories"),
});
expect(revalidated).toBe(false);
expect(calls).toEqual([]);
});
});

View File

@@ -0,0 +1,16 @@
export const SESSION_STALE_AFTER_MS = 60_000;
export async function revalidateSessionAfterResume(input: {
awayMs: number;
serverId: string;
bumpHistorySyncGeneration: (serverId: string) => void;
refreshDirectories: () => Promise<unknown>;
}): Promise<boolean> {
if (input.awayMs < SESSION_STALE_AFTER_MS) {
return false;
}
input.bumpHistorySyncGeneration(input.serverId);
await input.refreshDirectories();
return true;
}

View File

@@ -0,0 +1,63 @@
import { expect, it } from "vitest";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import { normalizeWorkspaceDescriptor } from "@/stores/session-store";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "./session-workspace-upserts";
import { reconcileWorkspaceDirectory } from "./workspace-directory-reconciliation";
const SERVER_ID = "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({
serverId: SERVER_ID,
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"],
]);
});
it("does not restore a locally archiving workspace from a buffered upsert", () => {
markWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "archiving" });
try {
const result = reconcileWorkspaceDirectory({
serverId: SERVER_ID,
snapshot: new Map(),
deltas: [{ kind: "upsert", workspace: workspace("archiving", "live") }],
});
expect(result.has("archiving")).toBe(false);
} finally {
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "archiving" });
}
});

View File

@@ -0,0 +1,26 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
import { shouldSuppressWorkspaceForLocalArchive } from "./session-workspace-upserts";
type WorkspaceDelta = Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
export function reconcileWorkspaceDirectory(input: {
serverId: string;
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);
if (shouldSuppressWorkspaceForLocalArchive({ serverId: input.serverId, workspace })) {
workspaces.delete(workspace.id);
} else {
workspaces.set(workspace.id, workspace);
}
}
}
return workspaces;
}

View File

@@ -79,6 +79,7 @@ describe("app diagnostics report", () => {
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
]),
clientGeneration: 1,
connectionEpoch: 1,
};
const report = formatHostRuntimeSection({ host, snapshot });

View File

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import type { HostRuntimeStore } from "@/runtime/host-runtime";
import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization";
import {
createSetAgentInitializing,
@@ -12,15 +13,28 @@ import {
const serverId = "server-1";
const agentId = "agent-1";
interface FakeDaemonClient {
fetchAgentTimeline: ReturnType<typeof vi.fn>;
refreshAgent: ReturnType<typeof vi.fn>;
class FakeDaemonClient {
readonly refreshedAgentIds: string[] = [];
async refreshAgent(requestedAgentId: string): Promise<void> {
this.refreshedAgentIds.push(requestedAgentId);
}
}
function makeClient(): FakeDaemonClient {
return {
fetchAgentTimeline: vi.fn().mockResolvedValue(undefined),
refreshAgent: vi.fn().mockResolvedValue(undefined),
class FakeTimelineRuntime {
readonly requests: Array<{
serverId: string;
agentId: string;
request: Parameters<HostRuntimeStore["fetchAgentTimeline"]>[2];
}> = [];
fetchAgentTimeline: HostRuntimeStore["fetchAgentTimeline"] = async (
requestedServerId,
requestedAgentId,
request,
) => {
this.requests.push({ serverId: requestedServerId, agentId: requestedAgentId, request });
return undefined as never;
};
}
@@ -36,7 +50,8 @@ afterEach(() => {
describe("ensureAgentIsInitialized", () => {
it("requests bounded projected catch-up after the current cursor when authoritative history is loaded", () => {
const client = makeClient();
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().initializeSession(serverId, client as never);
useSessionStore
.getState()
@@ -50,46 +65,63 @@ describe("ensureAgentIsInitialized", () => {
serverId,
agentId,
client: client as never,
runtime,
setAgentInitializing: bindSetAgentInitializing(),
});
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
});
expect(runtime.requests).toEqual([
{
serverId,
agentId,
request: {
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
},
},
]);
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
});
it("requests a bounded projected tail when no authoritative cursor is available", () => {
const client = makeClient();
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().initializeSession(serverId, client as never);
void ensureAgentIsInitialized({
serverId,
agentId,
client: client as never,
runtime,
setAgentInitializing: bindSetAgentInitializing(),
});
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
});
expect(runtime.requests).toEqual([
{
serverId,
agentId,
request: {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
},
},
]);
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
});
it("times out initialization after 65 seconds", async () => {
vi.useFakeTimers();
const client = makeClient();
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().initializeSession(serverId, client as never);
const promise = ensureAgentIsInitialized({
serverId,
agentId,
client: client as never,
runtime,
setAgentInitializing: bindSetAgentInitializing(),
});
@@ -108,7 +140,8 @@ describe("ensureAgentIsInitialized", () => {
it("refreshes the initialization timeout after paged catch-up progress", async () => {
vi.useFakeTimers();
const client = makeClient();
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().initializeSession(serverId, client as never);
const setAgentInitializing = bindSetAgentInitializing();
const key = getInitKey(serverId, agentId);
@@ -117,6 +150,7 @@ describe("ensureAgentIsInitialized", () => {
serverId,
agentId,
client: client as never,
runtime,
setAgentInitializing,
});
@@ -141,20 +175,29 @@ describe("ensureAgentIsInitialized", () => {
describe("refreshAgent", () => {
it("fetches a bounded projected tail after refreshing the agent", async () => {
const client = makeClient();
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().initializeSession(serverId, client as never);
await refreshAgent({
serverId,
agentId,
client: client as never,
runtime,
setAgentInitializing: bindSetAgentInitializing(),
});
expect(client.refreshAgent).toHaveBeenCalledWith(agentId);
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
});
expect(client.refreshedAgentIds).toEqual([agentId]);
expect(runtime.requests).toEqual([
{
serverId,
agentId,
request: {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
},
},
]);
});
});

View File

@@ -10,6 +10,7 @@ import {
rejectInitDeferred,
refreshInitTimeout,
} from "@/utils/agent-initialization";
import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { i18n } from "@/i18n/i18next";
@@ -37,6 +38,7 @@ export interface EnsureAgentIsInitializedInput {
serverId: string;
agentId: string;
client: Pick<DaemonClient, "fetchAgentTimeline"> | null;
runtime: Pick<HostRuntimeStore, "fetchAgentTimeline">;
setAgentInitializing: SetAgentInitializing;
hostDisconnectedMessage?: string;
}
@@ -68,7 +70,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
return deferred.promise;
}
client.fetchAgentTimeline(agentId, timelineRequest).catch((error) => {
input.runtime.fetchAgentTimeline(serverId, agentId, timelineRequest).catch((error) => {
setAgentInitializing(agentId, false);
rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error)));
});
@@ -77,14 +79,16 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
}
export interface RefreshAgentInput {
serverId: string;
agentId: string;
client: Pick<DaemonClient, "refreshAgent" | "fetchAgentTimeline"> | null;
client: Pick<DaemonClient, "refreshAgent"> | null;
runtime: Pick<HostRuntimeStore, "fetchAgentTimeline">;
setAgentInitializing: SetAgentInitializing;
hostDisconnectedMessage?: string;
}
export async function refreshAgent(input: RefreshAgentInput): Promise<void> {
const { agentId, client, setAgentInitializing } = input;
const { serverId, agentId, client, runtime, setAgentInitializing } = input;
if (!client) {
throw new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
}
@@ -92,7 +96,7 @@ export async function refreshAgent(input: RefreshAgentInput): Promise<void> {
try {
await client.refreshAgent(agentId);
await client.fetchAgentTimeline(agentId, planTimelineTailFetch());
await runtime.fetchAgentTimeline(serverId, agentId, planTimelineTailFetch());
} catch (error) {
setAgentInitializing(agentId, false);
throw error;
@@ -135,6 +139,7 @@ export function useAgentInitialization({
serverId,
agentId,
client,
runtime: getHostRuntimeStore(),
setAgentInitializing,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
@@ -144,12 +149,14 @@ export function useAgentInitialization({
const refreshAgentCallback = useCallback(
(agentId: string): Promise<void> =>
refreshAgent({
serverId,
agentId,
client,
runtime: getHostRuntimeStore(),
setAgentInitializing,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[client, setAgentInitializing, t],
[client, serverId, setAgentInitializing, t],
);
return {

View File

@@ -4,6 +4,7 @@ import type { ToastApi } from "@/components/toast-host";
import { i18n } from "@/i18n/i18next";
import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store";
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
export interface LoadOlderAgentHistoryClient {
fetchAgentTimeline: (
@@ -97,7 +98,12 @@ export function useLoadOlderAgentHistory({
const loadOlder = useCallback(() => {
const session = useSessionStore.getState().sessions[serverId];
void loadOlderAgentHistory(agentId, {
client: (session?.client ?? null) as LoadOlderAgentHistoryClient | null,
client: session?.client
? {
fetchAgentTimeline: (timelineAgentId, request) =>
getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request),
}
: null,
cursor: session?.agentTimelineCursor.get(agentId),
hasOlder: session?.agentTimelineHasOlder.get(agentId) === true,
isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true,

View File

@@ -13,6 +13,7 @@ import { useStoreWithEqualityFn } from "zustand/traditional";
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { Composer } from "@/composer";
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
@@ -26,7 +27,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 +41,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";
@@ -709,6 +707,7 @@ function ChatAgentContent({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const isPaneVisible = useRetainedPanelActive();
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
const { isArchivingAgent } = useArchiveAgent();
const streamViewRef = useRef<AgentStreamViewHandle>(null);
@@ -782,44 +781,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 +801,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 +900,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" });
@@ -976,16 +909,24 @@ function ChatAgentContent({
if (!agentId) {
return;
}
if (agentState.id) {
if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") {
if (agentState.id && hasAppliedAuthoritativeHistory) {
if (
missingAgentState.kind === "resolving" ||
missingAgentState.kind === "not_found" ||
missingAgentState.kind === "error"
) {
setMissingAgentState(reconcileMissingAgentStateWithPresentAgent);
}
return;
}
if (!isConnected || !hasSession) {
if (!isPaneVisible || !isConnected || !hasSession) {
return;
}
if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") {
if (
missingAgentState.kind === "resolving" ||
missingAgentState.kind === "not_found" ||
missingAgentState.kind === "error"
) {
return;
}
@@ -1033,11 +974,13 @@ function ChatAgentContent({
});
}, [
agentState.id,
hasAppliedAuthoritativeHistory,
agentId,
client,
ensureAgentIsInitialized,
hasSession,
isConnected,
isPaneVisible,
missingAgentState.kind,
serverId,
]);

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { useSessionStore } from "@/stores/session-store";
import { AgentDirectoryReplica } from "./agent-replica";
function payload(title: string): AgentSnapshotPayload {
return {
id: "agent",
provider: "codex",
cwd: "/repo",
model: null,
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:01:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title,
labels: {},
};
}
function entry(agent: AgentSnapshotPayload): FetchAgentsEntry {
return {
agent,
project: {
projectKey: "/repo",
projectName: "repo",
checkout: {
cwd: "/repo",
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
describe("AgentDirectoryReplica", () => {
it("keeps membership authoritative across remove, stale timeline, and re-add", () => {
const serverId = "agent-replica";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new AgentDirectoryReplica(serverId, () => undefined);
replica.commitSnapshot([entry(payload("directory"))], []);
const directoryPlacement = useSessionStore
.getState()
.sessions[serverId]?.agents.get("agent")?.projectPlacement;
expect(directoryPlacement).toBeDefined();
const staleToken = replica.captureTimeline("agent");
replica.remove("agent");
expect(replica.submitTimelineAgent(staleToken, payload("stale"))).toBe(false);
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(false);
replica.applyDelta({
kind: "upsert",
agent: payload("re-added"),
project: entry(payload("x")).project,
});
expect(replica.submitTimelineAgent(staleToken, payload("still stale"))).toBe(false);
const currentToken = replica.captureTimeline("agent");
expect(replica.submitTimelineAgent(currentToken, payload("current"))).toBe(true);
expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.title).toBe(
"current",
);
expect(
useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.projectPlacement,
).toEqual(directoryPlacement);
store.clearSession(serverId);
});
});

View File

@@ -0,0 +1,121 @@
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
applyAgentDirectoryDelta,
type AgentDirectoryDelta,
removeAgentDirectoryReplica,
replaceAgentPendingPermissions,
replaceFetchedAgentDirectory,
upsertAgentReplica,
} from "@/utils/agent-directory-sync";
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
export interface AgentLifecycleToken {
readonly agentId: string;
readonly version: number;
}
export class AgentDirectoryReplica {
private readonly lifecycleVersions = new Map<string, number>();
private readonly members = new Set<string>();
constructor(
private readonly serverId: string,
private readonly onStoppedRunning: (agentId: string) => void,
) {}
captureTimeline(agentId: string): AgentLifecycleToken {
return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 };
}
submitTimelineAgent(token: AgentLifecycleToken, payload: AgentSnapshotPayload): boolean {
if (
!this.members.has(token.agentId) ||
token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)
) {
return false;
}
const existing = useSessionStore.getState().sessions[this.serverId]?.agents.get(token.agentId);
const timelineAgent = applyLegacyDaemonWorkspaceOwnership({
serverId: this.serverId,
agent: normalizeAgentSnapshot(payload, this.serverId),
});
const normalized: Agent = {
...timelineAgent,
projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement,
};
const accepted = upsertAgentReplica(this.serverId, normalized);
replaceAgentPendingPermissions(this.serverId, accepted);
useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt);
if (accepted.archivedAt) {
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id });
}
return true;
}
applyDelta(delta: AgentDirectoryDelta): void {
const before = this.members.has(delta.kind === "remove" ? delta.agentId : delta.agent.id);
const result = applyAgentDirectoryDelta({ serverId: this.serverId, delta });
if (delta.kind === "remove") {
this.members.delete(delta.agentId);
this.advance(delta.agentId);
} else {
this.members.add(delta.agent.id);
if (!before) this.advance(delta.agent.id);
}
if (result.stoppedRunning) this.onStoppedRunning(result.agentId);
}
commitSnapshot(
entries: FetchAgentsEntry[],
deltas: readonly AgentDirectoryDelta[],
): Map<string, Agent> {
const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
const reconciled = reconcileAgentDirectory({ previous, snapshot: entries, deltas });
const nextIds = new Set(reconciled.entries.map((entry) => entry.agent.id));
for (const agentId of this.members) {
if (!nextIds.has(agentId)) this.advance(agentId);
}
for (const agentId of nextIds) {
if (!this.members.has(agentId)) this.advance(agentId);
}
for (const agentId of previous.keys()) {
if (!nextIds.has(agentId)) removeAgentDirectoryReplica(this.serverId, agentId);
}
this.members.clear();
for (const agentId of nextIds) this.members.add(agentId);
const { agents } = replaceFetchedAgentDirectory({
serverId: this.serverId,
entries: reconciled.entries,
});
for (const agentId of reconciled.stoppedRunningAgentIds) this.onStoppedRunning(agentId);
return agents;
}
archive(agentId: string, archivedAt: string): void {
this.advance(agentId);
useSessionStore.getState().setAgents(this.serverId, (current) => {
const agent = current.get(agentId);
if (!agent) return current;
const next = new Map(current);
next.set(agentId, { ...agent, archivedAt: new Date(archivedAt) });
return next;
});
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
}
remove(agentId: string): void {
this.members.delete(agentId);
this.advance(agentId);
removeAgentDirectoryReplica(this.serverId, agentId);
}
private advance(agentId: string): void {
this.lifecycleVersions.set(agentId, (this.lifecycleVersions.get(agentId) ?? 0) + 1);
}
}

View File

@@ -0,0 +1,113 @@
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
class FakeDirectoryClient {
fetchAgentsCalls = 0;
fetchWorkspacesCalls = 0;
on(): () => void {
return () => undefined;
}
async fetchAgents(): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
this.fetchAgentsCalls += 1;
return {
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
};
}
async fetchWorkspaces(): Promise<Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>> {
this.fetchWorkspacesCalls += 1;
return {
requestId: "workspaces",
entries: [],
emptyProjects: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
};
}
}
const serverIds = new Set<string>();
function createDirectory(serverId: string): {
client: FakeDirectoryClient;
directory: DirectorySync;
} {
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const directory = new DirectorySync(serverId, {
drainQueuedAgentMessage: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
});
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
return { client, directory };
}
afterEach(() => {
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
serverIds.clear();
});
describe("DirectorySync session readiness", () => {
it("waits for workspace capability metadata before choosing the workspace protocol", async () => {
const serverId = "workspace-metadata";
const { client, directory } = createDirectory(serverId);
const refresh = directory.refreshWorkspaces({ subscribe: true });
await Promise.resolve();
expect(client.fetchWorkspacesCalls).toBe(0);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
await Promise.resolve();
expect(client.fetchWorkspacesCalls).toBe(0);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
await refresh;
expect(client.fetchWorkspacesCalls).toBe(1);
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true);
directory.dispose();
});
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
const serverId = "session-wait-reconnect";
const { client, directory } = createDirectory(serverId);
const staleRefresh = directory.refreshAgents();
await Promise.resolve();
directory.connectionChanged({
client: null,
status: "offline",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
await expect(staleRefresh).rejects.toBeInstanceOf(DirectoryRefreshSupersededError);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 2 },
});
const currentRefresh = directory.refreshAgents();
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
await currentRefresh;
expect(client.fetchAgentsCalls).toBe(1);
directory.dispose();
});
});

View File

@@ -0,0 +1,416 @@
import type {
DaemonClient,
FetchAgentsEntry,
FetchAgentsOptions,
} from "@getpaseo/client/internal/daemon-client";
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
import {
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type Agent,
} from "@/stores/session-store";
import {
readLegacyDaemonWorkspaceDirectory,
buildLegacyWorkspaces,
shouldUseLegacyDaemonWorkspaceDirectory,
stampLegacyWorkspaceIds,
} from "@/workspace/legacy-daemon-workspaces";
import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { AgentDirectoryReplica } from "./agent-replica";
import {
WorkspaceDirectoryReplica,
type WorkspaceDirectoryDelta,
type WorkspaceDirectorySnapshot,
} from "./workspace-replica";
import {
DirectoryTransactionOwner,
type DirectorySourceToken,
type DirectoryTransaction,
} from "./transaction";
const PAGE_LIMIT = 200;
const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
{ key: "updated_at", direction: "desc" },
];
interface AgentSnapshot {
entries: FetchAgentsEntry[];
subscriptionId: string | null;
legacy: boolean;
}
export interface DirectoryConnection {
client: DaemonClient | null;
status: "online" | "offline";
source: DirectorySourceToken;
}
export interface RefreshAgentDirectoryInput {
filter?: FetchAgentsOptions["filter"];
subscribe?: FetchAgentsOptions["subscribe"];
page?: FetchAgentsOptions["page"];
}
export interface RefreshAgentDirectoryResult {
agents: Map<string, Agent>;
subscriptionId: string | null;
}
export class DirectorySync {
private readonly agentTransactions = new DirectoryTransactionOwner<
AgentSnapshot,
AgentDirectoryDelta
>();
private readonly workspaceTransactions = new DirectoryTransactionOwner<
WorkspaceDirectorySnapshot,
WorkspaceDirectoryDelta
>();
private readonly agents: AgentDirectoryReplica;
private readonly workspaces: WorkspaceDirectoryReplica;
private connection: DirectoryConnection = {
client: null,
status: "offline",
source: { clientGeneration: 0, connectionEpoch: 0 },
};
private unsubscribe: (() => void) | null = null;
private readonly abortSessionWaits = new Set<() => void>();
constructor(
private readonly serverId: string,
private readonly callbacks: {
drainQueuedAgentMessage: (agentId: string) => void;
markAgentLoading: () => void;
markAgentReady: () => void;
markAgentError: (error: string) => void;
},
) {
this.agents = new AgentDirectoryReplica(serverId, callbacks.drainQueuedAgentMessage);
this.workspaces = new WorkspaceDirectoryReplica(serverId);
}
connectionChanged(connection: DirectoryConnection): boolean {
const changed =
this.connection.client !== connection.client ||
this.connection.source.clientGeneration !== connection.source.clientGeneration ||
this.connection.source.connectionEpoch !== connection.source.connectionEpoch;
const wentOffline = this.connection.status === "online" && connection.status === "offline";
if (!changed && !wentOffline) {
this.connection = connection;
return false;
}
this.flushAbortedTransactions();
this.unsubscribe?.();
this.unsubscribe = null;
this.connection = connection;
this.abortPendingSessionWaits();
if (!connection.client || connection.status !== "online") return true;
const client = connection.client;
const source = connection.source;
const subscriptions = [
client.on("agent_update", (message) => {
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
if (!this.agentTransactions.record(source, message.payload))
this.agents.applyDelta(message.payload);
}),
client.on("workspace_update", (message) => {
if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return;
if (!this.workspaceTransactions.record(source, message.payload)) {
this.workspaces.applyDelta(message.payload);
}
}),
client.on("agent_deleted", (message) => {
if (message.type === "agent_deleted" && this.isCurrent(client, source)) {
this.agents.remove(message.payload.agentId);
}
}),
client.on("agent_archived", (message) => {
if (message.type === "agent_archived" && this.isCurrent(client, source)) {
this.agents.archive(message.payload.agentId, message.payload.archivedAt);
}
}),
];
this.unsubscribe = () => {
for (const unsubscribe of subscriptions) unsubscribe();
};
return true;
}
dispose(): void {
this.flushAbortedTransactions();
this.abortPendingSessionWaits();
this.unsubscribe?.();
this.unsubscribe = null;
}
async fetchTimeline(
agentId: string,
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
const { client } = this.requireOnline();
const token = this.agents.captureTimeline(agentId);
const page = await fetchAgentTimelineOnce(client, agentId, request);
if (page.agent) this.agents.submitTimelineAgent(token, page.agent);
return page;
}
async refreshAgents(
input: RefreshAgentDirectoryInput = {},
): Promise<RefreshAgentDirectoryResult> {
const { client, source } = this.requireOnline();
const transaction = this.agentTransactions.begin(source, () => ({
entries: [],
subscriptionId: null,
legacy: false,
}));
this.callbacks.markAgentLoading();
try {
await this.waitForSession(client, source);
const session = useSessionStore.getState().sessions[this.serverId];
if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) {
const directory = await readLegacyDaemonWorkspaceDirectory({
client,
subscribe: input.subscribe,
page: input.page,
});
if (
!directory ||
!this.agentTransactions.isCurrent(transaction) ||
!this.isCurrent(client, source)
) {
throw new DirectoryRefreshSupersededError("legacy fetch no longer current");
}
transaction.snapshot.entries.push(...stampLegacyWorkspaceIds(directory.entries));
transaction.snapshot.subscriptionId = directory.subscriptionId;
transaction.snapshot.legacy = true;
} else {
await this.fetchAgents(client, source, transaction, input);
}
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
throw new DirectoryRefreshSupersededError("agent completion no longer current");
}
const completion = this.agentTransactions.complete(transaction);
if (completion.kind === "stale") {
throw new DirectoryRefreshSupersededError("agent completion was superseded");
}
if (completion.snapshot.legacy) {
const store = useSessionStore.getState();
store.setWorkspaces(this.serverId, buildLegacyWorkspaces(completion.snapshot.entries));
store.setEmptyProjects(this.serverId, []);
store.setHasHydratedWorkspaces(this.serverId, true);
}
const deltas = completion.snapshot.legacy
? completion.deltas.map((delta) =>
delta.kind === "upsert"
? { ...delta, agent: { ...delta.agent, workspaceId: delta.agent.cwd } }
: delta,
)
: completion.deltas;
const agents = this.agents.commitSnapshot(completion.snapshot.entries, deltas);
this.callbacks.markAgentReady();
return { agents, subscriptionId: completion.snapshot.subscriptionId };
} catch (error) {
const deltas = this.agentTransactions.fail(transaction);
if (deltas) for (const delta of deltas) this.agents.applyDelta(delta);
if (!(error instanceof DirectoryRefreshSupersededError)) {
this.callbacks.markAgentError(error instanceof Error ? error.message : String(error));
}
throw error;
}
}
async refreshWorkspaces(input?: { subscribe?: boolean }): Promise<void> {
const { client, source } = this.requireOnline();
const transaction = this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(),
emptyProjects: new Map(),
}));
try {
await this.waitForSessionMetadata(client, source);
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
if (serverInfo?.features?.workspaceMultiplicity !== true) {
const deltas = this.workspaceTransactions.fail(transaction);
if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta);
return;
}
await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true);
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
}
const completion = this.workspaceTransactions.complete(transaction);
if (completion.kind === "stale") {
throw new DirectoryRefreshSupersededError("workspace completion was superseded");
}
this.workspaces.commitSnapshot(completion.snapshot, completion.deltas);
} catch (error) {
const deltas = this.workspaceTransactions.fail(transaction);
if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta);
throw error;
}
}
private async fetchWorkspaceSnapshot(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
initialSubscribe: boolean,
): Promise<void> {
let cursor: string | null = null;
let subscribe = initialSubscribe;
while (true) {
const payload = await client.fetchWorkspaces({
sort: [{ key: "activity_at", direction: "desc" }],
...(subscribe ? { subscribe: {} } : {}),
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
});
this.assertWorkspaceTransactionCurrent(client, source, transaction);
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
transaction.snapshot.workspaces.set(workspace.id, workspace);
}
for (const entry of payload.emptyProjects ?? []) {
const project = normalizeEmptyProjectDescriptor(entry);
transaction.snapshot.emptyProjects.set(project.projectId, project);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
cursor = payload.pageInfo.nextCursor;
subscribe = false;
}
}
async refreshAll(): Promise<void> {
await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]);
}
private async fetchAgents(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<AgentSnapshot, AgentDirectoryDelta>,
input: RefreshAgentDirectoryInput,
): Promise<void> {
let cursor = input.page?.cursor ?? null;
let subscribe = input.subscribe;
while (true) {
const limit = input.page?.limit ?? PAGE_LIMIT;
const payload = await client.fetchAgents({
...(input.filter ? { filter: input.filter } : { scope: "active" as const }),
sort: AGENT_SORT,
...(subscribe ? { subscribe } : {}),
page: cursor ? { limit, cursor } : { limit },
});
this.assertAgentTransactionCurrent(client, source, transaction);
transaction.snapshot.entries.push(...payload.entries);
transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null;
const pageInfo = payload.pageInfo as {
hasMore?: boolean;
hasMoreAfter?: boolean;
nextCursor?: string | null;
afterCursor?: string | null;
};
const hasMore = pageInfo.hasMore ?? pageInfo.hasMoreAfter ?? false;
const nextCursor = pageInfo.nextCursor ?? pageInfo.afterCursor ?? null;
if (!hasMore || !nextCursor) break;
cursor = nextCursor;
subscribe = undefined;
}
}
private assertAgentTransactionCurrent(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<AgentSnapshot, AgentDirectoryDelta>,
): void {
if (!this.agentTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
throw new DirectoryRefreshSupersededError("agent page no longer current");
}
}
private assertWorkspaceTransactionCurrent(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
): void {
if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
throw new DirectoryRefreshSupersededError("workspace fetch no longer current");
}
}
private requireOnline(): { client: DaemonClient; source: DirectorySourceToken } {
if (!this.connection.client || this.connection.status !== "online") {
throw new Error(`Host ${this.serverId} is not connected`);
}
return { client: this.connection.client, source: this.connection.source };
}
private isCurrent(client: DaemonClient, source: DirectorySourceToken): boolean {
return (
this.connection.client === client &&
this.connection.status === "online" &&
this.connection.source.clientGeneration === source.clientGeneration &&
this.connection.source.connectionEpoch === source.connectionEpoch
);
}
private async waitForSession(client: DaemonClient, source: DirectorySourceToken): Promise<void> {
await this.waitForSessionState(client, source, () => this.hasMatchingSession(client, source));
}
private async waitForSessionMetadata(
client: DaemonClient,
source: DirectorySourceToken,
): Promise<void> {
await this.waitForSessionState(client, source, () => {
const session = useSessionStore.getState().sessions[this.serverId];
return this.hasMatchingSession(client, source) && session?.serverInfo !== null;
});
}
private async waitForSessionState(
client: DaemonClient,
source: DirectorySourceToken,
matches: () => boolean,
): Promise<void> {
if (matches()) return;
await new Promise<void>((resolve, reject) => {
let settled = false;
let unsubscribe: () => void = () => undefined;
const finish = (result: "ready" | "aborted") => {
if (settled) return;
settled = true;
unsubscribe();
this.abortSessionWaits.delete(abort);
if (result === "ready") resolve();
else reject(new DirectoryRefreshSupersededError("session wait no longer current"));
};
const abort = () => finish("aborted");
const check = () => {
if (matches()) {
finish("ready");
} else if (!this.isCurrent(client, source)) {
finish("aborted");
}
};
this.abortSessionWaits.add(abort);
unsubscribe = useSessionStore.subscribe(check);
check();
});
}
private hasMatchingSession(client: DaemonClient, source: DirectorySourceToken): boolean {
const session = useSessionStore.getState().sessions[this.serverId];
return session?.client === client && session.clientGeneration === source.clientGeneration;
}
private flushAbortedTransactions(): void {
for (const delta of this.agentTransactions.abort()) this.agents.applyDelta(delta);
for (const delta of this.workspaceTransactions.abort()) this.workspaces.applyDelta(delta);
}
private abortPendingSessionWaits(): void {
for (const abort of this.abortSessionWaits) abort();
}
}
export class DirectoryRefreshSupersededError extends Error {}
export type { DirectorySourceToken } from "./transaction";

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { DirectoryTransactionOwner, type DirectorySourceToken } from "./transaction";
const epoch = (connectionEpoch: number): DirectorySourceToken => ({
clientGeneration: 1,
connectionEpoch,
});
type Event = "begin-new" | "first" | "second" | "finish-old" | "finish-new";
function permutations(events: Event[]): Event[][] {
if (events.length === 0) return [[]];
return events.flatMap((event, index) =>
permutations(events.filter((_, candidate) => candidate !== index)).map((tail) =>
[event].concat(tail),
),
);
}
describe("DirectoryTransactionOwner", () => {
it("applies every bounded two-refresh interleaving exactly once", () => {
const schedules = permutations([
"begin-new",
"first",
"second",
"finish-old",
"finish-new",
]).filter((events) => events.indexOf("begin-new") < events.indexOf("finish-new"));
expect(schedules).toHaveLength(60);
for (const events of schedules) {
const owner = new DirectoryTransactionOwner<null, string>();
const old = owner.begin(epoch(1), () => null);
let current = old;
const applied: string[] = [];
for (const event of events) {
if (event === "begin-new") current = owner.begin(epoch(1), () => null);
if (event === "first" || event === "second") {
if (!owner.record(epoch(1), event)) applied.push(event);
}
if (event === "finish-old") {
const result = owner.complete(old);
if (result.kind === "current") applied.push(...result.deltas);
}
if (event === "finish-new") {
const result = owner.complete(current);
if (result.kind === "current") applied.push(...result.deltas);
}
}
expect(applied.sort(), JSON.stringify(events)).toEqual(["first", "second"]);
}
});
it.each([
["success", false, false, ["delta"]],
["failure", true, false, ["delta"]],
["superseded success", false, true, ["delta"]],
["superseded failure", true, true, ["delta"]],
] as const)("preserves buffered deltas through %s", (_name, fail, supersede, expected) => {
const owner = new DirectoryTransactionOwner<string[], string>();
const first = owner.begin(epoch(1), () => []);
owner.record(epoch(1), "delta");
const current = supersede ? owner.begin(epoch(1), () => []) : first;
let deltas: readonly string[] | null;
if (fail) {
deltas = owner.fail(current);
} else {
const completion = owner.complete(current);
deltas = completion.kind === "current" ? completion.deltas : null;
}
expect(deltas).toEqual(expected);
if (supersede) expect(owner.complete(first)).toEqual({ kind: "stale" });
});
it("makes a stale completion inert", () => {
const owner = new DirectoryTransactionOwner<null, string>();
const first = owner.begin(epoch(1), () => null);
const second = owner.begin(epoch(1), () => null);
expect(owner.complete(first)).toEqual({ kind: "stale" });
expect(owner.isCurrent(second)).toBe(true);
});
it("isolates reconnect epochs", () => {
const owner = new DirectoryTransactionOwner<null, string>();
const disconnected = owner.begin(epoch(1), () => null);
owner.record(epoch(1), "old");
expect(owner.abort()).toEqual(["old"]);
const reconnected = owner.begin(epoch(2), () => null);
owner.record(epoch(2), "new");
expect(owner.complete(disconnected)).toEqual({ kind: "stale" });
expect(owner.complete(reconnected)).toEqual({
kind: "current",
snapshot: null,
deltas: ["new"],
});
});
});

View File

@@ -0,0 +1,70 @@
export interface DirectorySourceToken {
clientGeneration: number;
connectionEpoch: number;
}
export interface DirectoryTransaction<TSnapshot, TDelta> {
readonly source: DirectorySourceToken;
readonly snapshot: TSnapshot;
readonly deltas: TDelta[];
}
export type DirectoryTransactionCompletion<TSnapshot, TDelta> =
| { kind: "stale" }
| { kind: "current"; snapshot: TSnapshot; deltas: readonly TDelta[] };
function isSameSource(left: DirectorySourceToken, right: DirectorySourceToken): boolean {
return (
left.clientGeneration === right.clientGeneration &&
left.connectionEpoch === right.connectionEpoch
);
}
/** Owns the shared begin/record/supersede/complete lifecycle for a directory replica. */
export class DirectoryTransactionOwner<TSnapshot, TDelta> {
private current: DirectoryTransaction<TSnapshot, TDelta> | null = null;
begin(
source: DirectorySourceToken,
createSnapshot: () => TSnapshot,
): DirectoryTransaction<TSnapshot, TDelta> {
const transaction: DirectoryTransaction<TSnapshot, TDelta> = {
source,
snapshot: createSnapshot(),
deltas:
this.current && isSameSource(this.current.source, source) ? [...this.current.deltas] : [],
};
this.current = transaction;
return transaction;
}
record(source: DirectorySourceToken, delta: TDelta): boolean {
if (!this.current || !isSameSource(this.current.source, source)) return false;
this.current.deltas.push(delta);
return true;
}
isCurrent(transaction: DirectoryTransaction<TSnapshot, TDelta>): boolean {
return this.current === transaction;
}
complete(
transaction: DirectoryTransaction<TSnapshot, TDelta>,
): DirectoryTransactionCompletion<TSnapshot, TDelta> {
if (this.current !== transaction) return { kind: "stale" };
this.current = null;
return { kind: "current", snapshot: transaction.snapshot, deltas: transaction.deltas };
}
fail(transaction: DirectoryTransaction<TSnapshot, TDelta>): readonly TDelta[] | null {
const completion = this.complete(transaction);
return completion.kind === "current" ? completion.deltas : null;
}
abort(): readonly TDelta[] {
if (!this.current) return [];
const deltas = [...this.current.deltas];
this.current = null;
return deltas;
}
}

View File

@@ -0,0 +1,57 @@
import { expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { WorkspaceDirectoryReplica } from "./workspace-replica";
function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload {
return {
id,
projectId,
projectDisplayName: projectId,
projectRootPath: `/repo/${projectId}`,
workspaceDirectory: `/repo/${projectId}/${id}`,
projectKind: "git",
workspaceKind: "worktree",
name: id,
title: id,
status: "done",
activityAt: null,
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
it("commits workspace and project-parent state with filtered removals", () => {
const serverId = "workspace-replica";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const empty = normalizeEmptyProjectDescriptor({
projectId: "empty",
projectDisplayName: "Empty",
projectRootPath: "/repo/empty",
projectKind: "git",
});
replica.commitSnapshot(
{
workspaces: new Map([
["kept", normalizeWorkspaceDescriptor(workspace("kept"))],
["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))],
]),
emptyProjects: new Map([[empty.projectId, empty]]),
},
[{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }],
);
const session = useSessionStore.getState().sessions[serverId];
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["kept"]);
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]);
store.clearSession(serverId);
});

View File

@@ -0,0 +1,93 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type EmptyProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import {
clearWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
} from "@/contexts/session-workspace-upserts";
export type WorkspaceDirectoryDelta = Extract<
SessionOutboundMessage,
{ type: "workspace_update" }
>["payload"];
export interface WorkspaceDirectorySnapshot {
workspaces: Map<string, WorkspaceDescriptor>;
emptyProjects: Map<string, EmptyProjectDescriptor>;
}
export class WorkspaceDirectoryReplica {
constructor(private readonly serverId: string) {}
applyDelta(delta: WorkspaceDirectoryDelta): void {
const state = this.reconcile(this.read(), [delta]);
this.commit(state, delta.kind === "remove" ? [delta.id] : []);
}
commitSnapshot(
snapshot: WorkspaceDirectorySnapshot,
deltas: readonly WorkspaceDirectoryDelta[],
): void {
const removedWorkspaceIds = deltas.flatMap((delta) =>
delta.kind === "remove" ? [delta.id] : [],
);
this.commit(this.reconcile(snapshot, deltas), removedWorkspaceIds);
}
private read(): WorkspaceDirectorySnapshot {
const session = useSessionStore.getState().sessions[this.serverId];
return {
workspaces: new Map(session?.workspaces),
emptyProjects: new Map(session?.emptyProjects),
};
}
private reconcile(
snapshot: WorkspaceDirectorySnapshot,
deltas: readonly WorkspaceDirectoryDelta[],
): WorkspaceDirectorySnapshot {
const workspaces = new Map(snapshot.workspaces);
const emptyProjects = new Map(snapshot.emptyProjects);
for (const [workspaceId, workspace] of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) {
workspaces.delete(workspaceId);
}
}
for (const delta of deltas) {
if (delta.kind === "remove") {
workspaces.delete(delta.id);
if (delta.emptyProject) {
const project = normalizeEmptyProjectDescriptor(delta.emptyProject);
emptyProjects.set(project.projectId, project);
}
if (delta.removedProjectId) emptyProjects.delete(delta.removedProjectId);
continue;
}
const workspace = normalizeWorkspaceDescriptor(delta.workspace);
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) {
workspaces.delete(workspace.id);
} else {
workspaces.set(workspace.id, workspace);
emptyProjects.delete(workspace.projectId);
}
}
return { workspaces, emptyProjects };
}
private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void {
const store = useSessionStore.getState();
store.setWorkspaces(this.serverId, snapshot.workspaces);
store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values());
store.setHasHydratedWorkspaces(this.serverId, true);
for (const workspaceId of removedWorkspaceIds) {
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId });
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId });
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,6 @@ import equal from "fast-deep-equal/es6";
import {
DaemonClient,
type ConnectionState,
type FetchAgentsEntry,
type FetchAgentsOptions,
} from "@getpaseo/client/internal/daemon-client";
import {
@@ -40,12 +39,7 @@ 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,
shouldUseLegacyDaemonWorkspaceDirectory,
} from "@/workspace/legacy-daemon-workspaces";
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
import { queryClient } from "@/data/query-client";
import {
@@ -54,6 +48,13 @@ import {
} from "@/data/push-router";
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
import { sendQueuedComposerMessageNow } from "@/composer/actions";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { encodeImages } from "@/utils/encode-images";
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRegistryStatus = "loading" | "ready";
@@ -85,6 +86,7 @@ export interface HostRuntimeSnapshot {
hasEverLoadedAgentDirectory: boolean;
probeByConnectionId: Map<string, ConnectionProbeState>;
clientGeneration: number;
connectionEpoch: number;
}
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
@@ -176,90 +178,6 @@ const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200;
const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000;
const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
{ key: "updated_at", direction: "desc" },
];
function readFetchAgentsHasMore(
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
): boolean {
const page = pageInfo as {
hasMore?: boolean;
hasMoreAfter?: boolean;
};
if (typeof page.hasMore === "boolean") {
return page.hasMore;
}
if (typeof page.hasMoreAfter === "boolean") {
return page.hasMoreAfter;
}
return false;
}
function readFetchAgentsNextCursor(
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
): string | null {
const page = pageInfo as {
nextCursor?: string | null;
afterCursor?: string | null;
};
if (typeof page.nextCursor === "string" && page.nextCursor.length > 0) {
return page.nextCursor;
}
if (typeof page.afterCursor === "string" && page.afterCursor.length > 0) {
return page.afterCursor;
}
return null;
}
interface AgentDirectoryFetchInput {
client: DaemonClient;
filter?: FetchAgentsOptions["filter"];
subscribe?: FetchAgentsOptions["subscribe"];
page?: FetchAgentsOptions["page"];
}
interface AgentDirectoryFetchResult {
entries: FetchAgentsEntry[];
subscriptionId: string | null;
}
async function fetchCurrentAgentDirectory(
input: AgentDirectoryFetchInput,
): Promise<AgentDirectoryFetchResult> {
const pageLimit = input.page?.limit ?? DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT;
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({
scope: input.filter ? undefined : "active",
...(input.filter ? { filter: input.filter } : {}),
sort: DEFAULT_AGENT_DIRECTORY_SORT,
...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}),
page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit },
});
entries.push(...payload.entries);
subscriptionId = subscriptionId ?? payload.subscriptionId ?? null;
includeSubscribe = false;
if (!readFetchAgentsHasMore(payload.pageInfo)) {
break;
}
const nextCursor = readFetchAgentsNextCursor(payload.pageInfo);
if (!nextCursor) {
break;
}
cursor = nextCursor;
}
return { entries, subscriptionId };
}
function toActiveConnection(connection: HostConnection): ActiveConnection {
if (connection.type === "directSocket") {
return {
@@ -445,9 +363,15 @@ function nextConnectionMachineState(input: {
function toSnapshotConnectionPatch(
state: HostRuntimeConnectionMachineState,
connectionEpoch: number,
): Pick<
HostRuntimeSnapshot,
"activeConnectionId" | "activeConnection" | "connectionStatus" | "lastError" | "lastOnlineAt"
| "activeConnectionId"
| "activeConnection"
| "connectionStatus"
| "lastError"
| "lastOnlineAt"
| "connectionEpoch"
> {
if (state.tag === "booting") {
return {
@@ -456,6 +380,7 @@ function toSnapshotConnectionPatch(
connectionStatus: "connecting",
lastError: null,
lastOnlineAt: null,
connectionEpoch,
};
}
if (state.tag === "connecting") {
@@ -465,6 +390,7 @@ function toSnapshotConnectionPatch(
connectionStatus: "connecting",
lastError: null,
lastOnlineAt: null,
connectionEpoch,
};
}
if (state.tag === "online") {
@@ -474,6 +400,7 @@ function toSnapshotConnectionPatch(
connectionStatus: "online",
lastError: null,
lastOnlineAt: state.lastOnlineAt,
connectionEpoch,
};
}
if (state.tag === "offline") {
@@ -483,6 +410,7 @@ function toSnapshotConnectionPatch(
connectionStatus: "offline",
lastError: null,
lastOnlineAt: null,
connectionEpoch,
};
}
return {
@@ -491,6 +419,7 @@ function toSnapshotConnectionPatch(
connectionStatus: "error",
lastError: state.message,
lastOnlineAt: null,
connectionEpoch,
};
}
@@ -538,6 +467,10 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
},
}
: undefined;
const appCapabilities = {
[CLIENT_CAPS.selectiveAgentTimeline]: true,
...browserAutomationCapabilities,
};
return {
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
@@ -548,7 +481,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration,
...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}),
capabilities: appCapabilities,
};
if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({
@@ -586,7 +519,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
connectToDaemon(connection, {
...(host.serverId ? { serverId: host.serverId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}),
capabilities: appCapabilities,
}),
getClientId: () => getOrCreateClientId(),
mountClientHandlers: ({ client, host }) => {
@@ -614,6 +547,7 @@ export class HostRuntimeController {
private deps: HostRuntimeControllerDeps;
private onReconcileServerId: ((oldId: string, newId: string) => void) | null;
private connectionMachineState: HostRuntimeConnectionMachineState;
private connectionEpoch = 0;
private snapshot: HostRuntimeSnapshot;
private listeners = new Set<() => void>();
private activeClient: DaemonClient | null = null;
@@ -644,7 +578,7 @@ export class HostRuntimeController {
};
this.snapshot = {
serverId: this.host.serverId,
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
client: null,
agentDirectoryStatus: "idle",
agentDirectoryError: null,
@@ -712,7 +646,7 @@ export class HostRuntimeController {
}
this.applyConnectionEvent({ type: "stopped" });
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
client: null,
});
}
@@ -776,7 +710,7 @@ export class HostRuntimeController {
markStartupError(message: string): void {
this.applyConnectionEvent({ type: "connect_failed", message });
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
});
}
@@ -809,7 +743,7 @@ export class HostRuntimeController {
}
this.applyConnectionEvent({ type: "no_connections" });
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
probeByConnectionId: new Map(),
});
return;
@@ -1095,6 +1029,9 @@ export class HostRuntimeController {
state: previousState,
event,
});
if (previousState.tag !== "online" && nextState.tag === "online") {
this.connectionEpoch += 1;
}
this.connectionMachineState = nextState;
this.logConnectionTransition({
from: previousState.tag,
@@ -1171,7 +1108,7 @@ export class HostRuntimeController {
message: `Failed to resolve client id: ${message}`,
});
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
});
return null;
}
@@ -1269,7 +1206,7 @@ export class HostRuntimeController {
this.snapshot = {
...this.snapshot,
serverId: this.host.serverId,
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
client,
clientGeneration: nextGeneration,
};
@@ -1287,7 +1224,7 @@ export class HostRuntimeController {
lastError: client.lastError,
});
const patch: HostRuntimeSnapshotPatch = {
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
...this.buildAgentDirectoryStatusPatch(),
};
this.updateSnapshot(patch);
@@ -1307,7 +1244,7 @@ export class HostRuntimeController {
message,
});
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
});
}
}
@@ -1393,6 +1330,13 @@ function rekeyMap<V>(map: Map<string, V>, oldKey: string, newKey: string): void
map.set(newKey, value);
}
interface AgentDirectoryRefreshInput {
serverId: string;
filter?: FetchAgentsOptions["filter"];
subscribe?: FetchAgentsOptions["subscribe"];
page?: FetchAgentsOptions["page"];
}
export class HostRuntimeStore {
private controllers = new Map<string, HostRuntimeController>();
private serverListeners = new Map<string, Set<() => void>>();
@@ -1406,6 +1350,8 @@ export class HostRuntimeStore {
private deps: HostRuntimeControllerDeps;
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
private queuedAgentDrainInFlight = new Set<string>();
private directorySyncByServer = new Map<string, DirectorySync>();
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
private bootStarted = false;
private storage: HostRuntimeStorage;
@@ -1636,6 +1582,27 @@ export class HostRuntimeStore {
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
rekeyMap(this.agentDirectoryBootstrapInFlight, oldServerId, newServerId);
this.directorySyncByServer.get(oldServerId)?.dispose();
this.directorySyncByServer.delete(oldServerId);
const directory = new DirectorySync(newServerId, {
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => {
this.agentDirectoryBootstrapInFlight.delete(newServerId);
controller.markAgentDirectorySyncReady();
},
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
});
this.directorySyncByServer.set(newServerId, directory);
const snapshot = controller.getSnapshot();
directory.connectionChanged({
client: snapshot.client,
status: snapshot.connectionStatus === "online" ? "online" : "offline",
source: {
clientGeneration: snapshot.clientGeneration,
connectionEpoch: snapshot.connectionEpoch,
},
});
const listeners = this.serverListeners.get(oldServerId);
if (listeners) {
@@ -1926,6 +1893,8 @@ export class HostRuntimeStore {
this.controllers.delete(serverId);
this.lastConnectionStatusByServer.delete(serverId);
this.agentDirectoryBootstrapInFlight.delete(serverId);
this.directorySyncByServer.get(serverId)?.dispose();
this.directorySyncByServer.delete(serverId);
void controller.stop();
this.emit(serverId);
}
@@ -1948,6 +1917,19 @@ export class HostRuntimeStore {
onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId),
});
this.controllers.set(host.serverId, controller);
this.directorySyncByServer.set(
host.serverId,
new DirectorySync(host.serverId, {
drainQueuedAgentMessage: (agentId) =>
this.drainQueuedAgentMessage(host.serverId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => {
this.agentDirectoryBootstrapInFlight.delete(host.serverId);
controller.markAgentDirectorySyncReady();
},
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
}),
);
this.lastConnectionStatusByServer.set(
host.serverId,
controller.getSnapshot().connectionStatus,
@@ -1980,6 +1962,15 @@ export class HostRuntimeStore {
return;
}
const snapshot = controller.getSnapshot();
const directorySourceChanged =
this.directorySyncByServer.get(serverId)?.connectionChanged({
client: snapshot.client,
status: snapshot.connectionStatus === "online" ? "online" : "offline",
source: {
clientGeneration: snapshot.clientGeneration,
connectionEpoch: snapshot.connectionEpoch,
},
}) ?? false;
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus);
const didTransitionOnline =
@@ -2002,17 +1993,20 @@ export class HostRuntimeStore {
if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) {
return;
}
if (this.agentDirectoryBootstrapInFlight.has(serverId)) {
if (this.agentDirectoryBootstrapInFlight.has(serverId) && !directorySourceChanged) {
return;
}
const bootstrap = Promise.resolve()
.then(() =>
this.refreshAgentDirectory({
serverId,
subscribe: { subscriptionId: `app:${serverId}` },
page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT },
}),
Promise.all([
this.refreshAgentDirectory({
serverId,
subscribe: { subscriptionId: `app:${serverId}` },
page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT },
}),
this.refreshWorkspaceDirectory({ serverId, subscribe: true }),
]),
)
.then(() => undefined)
.catch((error) => {
@@ -2031,6 +2025,55 @@ export class HostRuntimeStore {
this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap);
}
drainQueuedAgentMessage(serverId: string, agentId: string): void {
const drainKey = `${serverId}:${agentId}`;
if (this.queuedAgentDrainInFlight.has(drainKey)) return;
const store = useSessionStore.getState();
const session = store.sessions[serverId];
const queue = session?.queuedMessages.get(agentId);
const client = session?.client;
if (!client || !queue?.length || session.initializingAgents.get(agentId) === true) {
return;
}
this.queuedAgentDrainInFlight.add(drainKey);
const next = queue[0];
void sendQueuedComposerMessageNow({
agentId,
messageId: next.id,
queue: {
read: (queuedAgentId) =>
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
},
submitMessage: async ({ text, attachments }) => {
const supportsForgeAttachments =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({ supportsForgeAttachments }),
});
const images = await encodeImages(wirePayload.images);
await client.sendAgentMessage(agentId, text, {
messageId: next.id,
...(images && images.length > 0 ? { images } : {}),
attachments: wirePayload.attachments,
});
},
})
.then((result) => {
if (result.status === "failed") {
console.error("[HostRuntime] failed to drain queued agent message", {
serverId,
agentId,
error: result.errorMessage,
});
}
return result;
})
.finally(() => {
this.queuedAgentDrainInFlight.delete(drainKey);
});
}
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
return this.controllers.get(serverId)?.getSnapshot() ?? null;
}
@@ -2095,63 +2138,34 @@ 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;
}> {
const controller = this.controllers.get(input.serverId);
if (!controller) {
throw new Error(`Unknown host runtime for serverId ${input.serverId}`);
}
const snapshot = controller.getSnapshot();
const client = controller.getClient();
if (!client || snapshot.connectionStatus !== "online") {
throw new Error(`Host ${input.serverId} is not connected`);
}
async refreshAgentDirectory(
input: AgentDirectoryRefreshInput,
): Promise<RefreshAgentDirectoryResult> {
const directory = this.directorySyncByServer.get(input.serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${input.serverId}`);
return directory.refreshAgents(input);
}
controller.markAgentDirectorySyncLoading();
try {
const session = useSessionStore.getState().sessions[input.serverId];
if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) {
const result = await fetchLegacyDaemonWorkspaceDirectory({
client,
serverId: input.serverId,
subscribe: input.subscribe,
page: input.page,
});
controller.markAgentDirectorySyncReady();
return {
agents: result.agents,
subscriptionId: result.subscriptionId,
};
}
async refreshWorkspaceDirectory(input: { serverId: string; subscribe?: boolean }): Promise<void> {
const directory = this.directorySyncByServer.get(input.serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${input.serverId}`);
await directory.refreshWorkspaces({ subscribe: input.subscribe });
}
const directory = await fetchCurrentAgentDirectory({
client,
filter: input.filter,
subscribe: input.subscribe,
page: input.page,
});
async refreshDirectories(serverId: string): Promise<void> {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
await directory.refreshAll();
}
const { agents } = replaceFetchedAgentDirectory({
serverId: input.serverId,
entries: directory.entries,
});
controller.markAgentDirectorySyncReady();
return {
agents,
subscriptionId: directory.subscriptionId,
};
} catch (error) {
controller.markAgentDirectorySyncError(toErrorMessage(error));
throw error;
}
fetchAgentTimeline(
serverId: string,
agentId: string,
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
return directory.fetchTimeline(agentId, request);
}
refreshAllAgentDirectories(input?: { serverIds?: string[] }): void {

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 || !supportsDesktopPaneSplits(),
}),
[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(() => {
@@ -2740,7 +2766,7 @@ function WorkspaceScreenContent({
// dropped against the stale cursor.
const sessionState = useSessionStore.getState().sessions[normalizedServerId];
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
await client.fetchAgentTimeline(agentId, {
await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, {
direction: "tail",
projection: "projected",
...(currentCursor

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

@@ -0,0 +1,30 @@
import { expect, test } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { fetchAgentTimelineOnce } from "./fetch-agent-timeline-once";
type TimelinePage = Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>;
test("concurrent identical timeline reads share one request", async () => {
let resolvePage: (page: TimelinePage) => void = () => {};
const page = new Promise<TimelinePage>((resolve) => {
resolvePage = resolve;
});
const requests: Array<{ agentId: string; direction: string }> = [];
const client = {
fetchAgentTimeline: async (
agentId: string,
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
) => {
requests.push({ agentId, direction: request?.direction ?? "tail" });
return page;
},
};
const request = { direction: "tail" as const, limit: 100, projection: "projected" as const };
const first = fetchAgentTimelineOnce(client, "agent", request);
const second = fetchAgentTimelineOnce(client, "agent", request);
resolvePage({ hasNewer: false } as TimelinePage);
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
expect(requests).toEqual([{ agentId: "agent", direction: "tail" }]);
});

View File

@@ -0,0 +1,31 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
type TimelineClient = Pick<DaemonClient, "fetchAgentTimeline">;
type TimelineRequest = Parameters<TimelineClient["fetchAgentTimeline"]>[1];
type TimelinePage = Awaited<ReturnType<TimelineClient["fetchAgentTimeline"]>>;
const inFlightByClient = new WeakMap<object, Map<string, Promise<TimelinePage>>>();
export function fetchAgentTimelineOnce(
client: TimelineClient,
agentId: string,
request: TimelineRequest,
): Promise<TimelinePage> {
let inFlight = inFlightByClient.get(client);
if (!inFlight) {
inFlight = new Map();
inFlightByClient.set(client, inFlight);
}
const key = `${agentId}:${JSON.stringify(request)}`;
const existing = inFlight.get(key);
if (existing) return existing;
const fetch = client.fetchAgentTimeline(agentId, request);
inFlight.set(key, fetch);
const clear = () => {
if (inFlight.get(key) === fetch) inFlight.delete(key);
};
void fetch.then(clear, clear);
return fetch;
}

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,448 @@
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),
hasAuthoritativeHistory: (agentId) => this.authoritativeHistory.has(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 authoritativeHistory = new Set<string>();
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 });
this.authoritativeHistory.add(agentId);
}
setLiveCursor(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("uses a tail fetch when a live cursor is not authoritative", async () => {
const world = new TimelineWorld();
world.setLiveCursor("agent-a", 9);
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const fetch = await world.nextFetch("agent-a");
expect(fetch.request).toEqual({ direction: "tail", limit: 100, projection: "projected" });
fetch.respond({ hasNewer: false });
});
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("repeated recovery for the same running gap reuses the in-flight fetch", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const initial = await world.nextFetch("agent-a");
initial.respond({ hasNewer: false });
const cursor = { epoch: "epoch-agent-a", endSeq: 10 };
world.sync.recoverGap("agent-a", cursor);
const gapPage = await world.nextFetch("agent-a");
world.sync.recoverGap("agent-a", cursor);
world.expectNoPendingFetch();
gapPage.respond({ hasNewer: false });
});
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,321 @@
import type { AgentTimelineCursorState } from "@/stores/session-store";
import {
planInitialAgentTimelineSync,
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;
hasAuthoritativeHistory(agentId: string): boolean;
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;
request?: ProjectedTimelineForwardFetchPlan;
cancelRetry?: () => void;
}
function isSameCatchUpRequest(
left: ProjectedTimelineForwardFetchPlan | undefined,
right: ProjectedTimelineForwardFetchPlan | undefined,
): boolean {
if (!left || !right || left.direction !== right.direction) return false;
if (left.direction !== "after" || right.direction !== "after") return true;
return left.cursor.epoch === right.cursor.epoch && left.cursor.seq === right.cursor.seq;
}
function shouldKeepCurrentCatchUp(input: {
current: CatchUpState | undefined;
request: ProjectedTimelineForwardFetchPlan | undefined;
supersede: boolean;
}): boolean {
if (!input.current) return false;
if (input.supersede) {
return (
input.current.status === "running" &&
isSameCatchUpRequest(input.current.request, input.request)
);
}
return input.current.status === "running" || input.current.status === "complete";
}
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 (shouldKeepCurrentCatchUp({ current, request, supersede })) {
return;
}
current?.cancelRetry?.();
const generation = (catchUpGenerations.get(agentId) ?? 0) + 1;
catchUpGenerations.set(agentId, generation);
catchUps.set(agentId, { generation, status: "running", request });
pendingGaps.delete(agentId);
const cursor = ports.readCursor(agentId);
const nextRequest =
request ??
(ports.hasAuthoritativeHistory(agentId)
? planResumeTimelineSync({ cursor })
: planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory: false }));
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,240 @@
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 snapshotEntry = entry("agent", "running");
const staleProject = {
...snapshotEntry.project,
projectName: "stale project",
};
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: staleProject,
},
],
});
expect({
title: result.entries[0]?.agent.title,
status: result.entries[0]?.agent.status,
projectName: result.entries[0]?.project.projectName,
stopped: result.stoppedRunningAgentIds,
}).toEqual({ title: "newer page", status: "running", projectName: "repo", stopped: [] });
});
it("clears a snapshot stop when a newer buffered upsert is running", () => {
const result = reconcileAgentDirectory({
previous: new Map([["agent", replica("agent", "running")]]),
snapshot: [entry("agent", "idle")],
deltas: [
{
kind: "upsert",
agent: {
...snapshot("agent", "running"),
updatedAt: "2026-07-12T11:00:00.000Z",
},
project: entry("agent", "running").project,
},
],
});
expect(result.entries[0]?.agent.status).toBe("running");
expect(result.stoppedRunningAgentIds).toEqual([]);
});
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 },
});
});
it("preserves usage when a stale buffered upsert omits it", () => {
const result = reconcileAgentDirectory({
previous: new Map(),
snapshot: [
{
...entry("agent", "idle"),
agent: {
...snapshot("agent", "idle"),
updatedAt: "2026-07-12T12:00:00.000Z",
lastUsage: { inputTokens: 10, outputTokens: 5 },
},
},
],
deltas: [
{
kind: "upsert",
agent: {
...snapshot("agent", "running"),
updatedAt: "2026-07-12T11:00:00.000Z",
},
project: entry("agent", "idle").project,
},
],
});
expect(result.entries[0]?.agent.lastUsage).toEqual({ inputTokens: 10, outputTokens: 5 });
});
});

View File

@@ -0,0 +1,62 @@
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 (acceptedAgent.status === "running") {
stoppedRunningAgentIds.delete(delta.agent.id);
} else if (statuses.get(delta.agent.id) === "running") {
stoppedRunningAgentIds.add(delta.agent.id);
}
statuses.set(delta.agent.id, acceptedAgent.status);
const previousProject = previousEntry?.project;
const acceptedProject =
acceptedAgent === delta.agent ? (delta.project ?? previousProject) : previousProject;
entries.set(delta.agent.id, {
agent: acceptedAgent,
project: acceptedProject ?? {
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,7 +60,28 @@ function createEntry(agent: AgentSnapshotPayload): FetchAgentsEntry {
};
}
function permission(id: string): AgentPermissionRequest {
return { id, provider: "codex", name: id, kind: "tool", title: id };
}
describe("replaceFetchedAgentDirectory", () => {
it("preserves timeline initialization while replacing directory state", () => {
const serverId = "server-initializing";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
store.setInitializingAgents(serverId, new Map([["agent", true]]));
replaceFetchedAgentDirectory({
serverId,
entries: [createEntry(createAgentPayload({ id: "agent" }))],
});
expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get("agent")).toBe(
true,
);
store.clearSession(serverId);
});
it("re-derives parentAgentId every time an agent snapshot is ingested", () => {
const serverId = "server-1";
const store = useSessionStore.getState();
@@ -92,4 +117,142 @@ 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 }]]),
);
store.setInitializingAgents(serverId, new Map([[agentId, true]]));
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,
initializing: session?.initializingAgents.has(agentId),
archivePending: isAgentArchiving({ queryClient, serverId, agentId }),
}).toEqual({
agents: false,
details: false,
queued: false,
cursor: false,
permissions: 0,
initializing: false,
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,132 @@ 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";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization";
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",
};
}
export 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;
}
export 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);
}
export 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.setInitializingAgents(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);
store.setAgentStreamTail(serverId, removeKey);
store.clearAgentStreamHead(serverId, agentId);
useSessionStore.setState((state) => {
if (!state.agentLastActivity.has(agentId)) return state;
const agentLastActivity = new Map(state.agentLastActivity);
agentLastActivity.delete(agentId);
return { ...state, agentLastActivity };
});
useDraftStore.getState().clearDraftInput({
draftKey: buildDraftStoreKey({ serverId, agentId }),
});
const initKey = getInitKey(serverId, agentId);
if (getInitDeferred(initKey)) {
rejectInitDeferred(initKey, new Error("Agent was removed during initialization"));
}
}
interface PendingPermissionEntry {
key: string;
@@ -49,6 +173,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;
@@ -69,7 +199,6 @@ export function replaceFetchedAgentDirectory(input: {
store.setAgentLastActivityBatch(lastActivityByAgentId);
store.setPendingPermissions(input.serverId, new Map(pendingPermissions));
store.setInitializingAgents(input.serverId, new Map());
store.setHasHydratedAgents(input.serverId, true);
return { agents: fetchedAgents };
}

View File

@@ -0,0 +1,21 @@
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 (incoming.lastUsage === undefined) return current;
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 {
@@ -255,7 +255,9 @@ function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[
});
}
function buildLegacyWorkspaces(entries: FetchAgentsEntry[]): Map<string, WorkspaceDescriptor> {
export function buildLegacyWorkspaces(
entries: FetchAgentsEntry[],
): Map<string, WorkspaceDescriptor> {
const workspaces = new Map<string, WorkspaceDescriptor>();
for (const entry of entries) {
const workspaceId = entry.agent.workspaceId ?? resolveLegacyWorkspaceId(entry);

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 } : {}),
},
},
}),
@@ -185,6 +186,7 @@ test("does not infer browser automation capabilities from Electron runtime", asy
})
.parse(JSON.parse(assertStr(mock.sent[0])));
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined();
expect(hello.capabilities[CLIENT_CAPS.selectiveAgentTimeline]).toBeUndefined();
});
test("advertises consumer-provided browser automation capabilities", async () => {
@@ -219,6 +221,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: () => {},

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 = {},

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

@@ -3033,11 +3033,11 @@ describe("OpenCode provider subagent contract", () => {
event: {
type: "timeline",
id: "ses_child_with_history",
item: {
item: expect.objectContaining({
type: "assistant_message",
text: "Persisted child result.",
messageId: "msg_child_history",
},
}),
timestamp: "1970-01-01T00:00:02.000Z",
},
}),
@@ -3111,11 +3111,11 @@ describe("OpenCode provider subagent contract", () => {
event: expect.objectContaining({
type: "timeline",
id: "ses_child_hydrating",
item: {
item: expect.objectContaining({
type: "assistant_message",
text: "Hydration did not lose this.",
messageId: "msg_child_hydrating",
},
}),
}),
}),
),

View File

@@ -0,0 +1,287 @@
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("subscription acknowledgements stay on the requesting socket of a retained session", async () => {
const legacy = await connect({ clientId: "shared-client", selective: false });
const capable = await connect({ clientId: "shared-client", selective: true });
legacy.clear();
capable.clear();
await capable.client.setAgentTimelineSubscription(["agent-a"]);
await capable.barrier("targeted-subscription-ack");
expect(
legacy.messages.some((message) => message.type === "agent.timeline.set_subscription.response"),
).toBe(false);
});
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

@@ -10,6 +10,7 @@ import {
assertPullRequestAutoMergeEnableReady,
} from "../services/github-service.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
decodeFileTransferFrame,
@@ -21,6 +22,7 @@ import { Session } from "./session.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentManagerEvent } from "./agent/agent-manager.js";
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import type { SessionOptions } from "./session.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
@@ -309,6 +311,7 @@ interface SessionForTestOptions {
daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"];
downloadTokenStore?: SessionOptions["downloadTokenStore"];
messages?: unknown[];
targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>;
binaryMessages?: Uint8Array[];
}
@@ -346,6 +349,12 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
return new Session({
clientId: "test-client",
onMessage: (message) => messages.push(message),
...(options.targetedMessages
? {
onMessageToSource: (source: object, message: SessionOutboundMessage) =>
options.targetedMessages?.push({ source, message }),
}
: {}),
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
logger,
downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(),
@@ -4554,6 +4563,216 @@ 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",
},
},
]);
});
test("acknowledges a timeline subscription only to its socket source", async () => {
const messages: SessionOutboundMessage[] = [];
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
const session = createSessionForTest({ messages, targetedMessages });
const capableSocket = {};
session.updateClientCapabilities({ selective_agent_timeline: true }, capableSocket);
await session.handleMessage(
{
type: "agent.timeline.set_subscription.request",
agentIds: ["agent-a"],
requestId: "timeline-subscription-targeted",
},
capableSocket,
);
expect(messages).toEqual([]);
expect(targetedMessages).toEqual([
{
source: capableSocket,
message: {
type: "agent.timeline.set_subscription.response",
payload: {
agentIds: ["agent-a"],
requestId: "timeline-subscription-targeted",
},
},
},
]);
});
test("unions viewed timelines across socket sources and removes detached sources", async () => {
const messages: SessionOutboundMessage[] = [];
const agentEventListeners: Array<(event: AgentManagerEvent) => void> = [];
const session = createSessionForTest({
messages,
agentManager: {
subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => {
agentEventListeners.push(listener);
return () => {};
}),
},
});
session.updateClientCapabilities({ selective_agent_timeline: true });
const firstSocket = {};
const secondSocket = {};
session.updateClientCapabilities({ selective_agent_timeline: true }, firstSocket);
session.updateClientCapabilities({ selective_agent_timeline: true }, secondSocket);
await session.handleMessage(
{
type: "agent.timeline.set_subscription.request",
agentIds: ["agent-a"],
requestId: "timeline-subscription-a",
},
firstSocket,
);
await session.handleMessage(
{
type: "agent.timeline.set_subscription.request",
agentIds: ["agent-b"],
requestId: "timeline-subscription-b",
},
secondSocket,
);
messages.length = 0;
if (agentEventListeners.length === 0) throw new Error("Agent event listener was not installed");
const forward = (event: AgentManagerEvent) => {
for (const listener of agentEventListeners) listener(event);
};
forward({
type: "agent_stream",
agentId: "agent-a",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-a", text: "A" },
},
});
forward({
type: "agent_stream",
agentId: "agent-b",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-b", text: "B" },
},
});
expect(messages.filter((message) => message.type === "agent_stream")).toHaveLength(2);
const legacySocket = {};
session.updateClientCapabilities(null, legacySocket);
expect(session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, legacySocket)).toBe(false);
expect(session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, firstSocket)).toBe(true);
messages.length = 0;
forward({
type: "agent_stream",
agentId: "agent-not-viewed",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-legacy", text: "legacy" },
},
});
expect(messages.some((message) => message.type === "agent_stream")).toBe(true);
session.clearAgentTimelineSubscription(legacySocket);
session.clearAgentTimelineSubscription(firstSocket);
messages.length = 0;
forward({
type: "agent_stream",
agentId: "agent-a",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-a-2", text: "detached A" },
},
});
forward({
type: "agent_stream",
agentId: "agent-b",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-b-2", text: "retained B" },
},
});
expect(
messages.flatMap((message) =>
message.type === "agent_stream" ? [message.payload.agentId] : [],
),
).toEqual(["agent-b"]);
});
test("keeps selective delivery scoped per socket when a retained session also has a legacy socket", async () => {
const messages: SessionOutboundMessage[] = [];
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
const agentEventListeners: Array<(event: AgentManagerEvent) => void> = [];
const session = createSessionForTest({
messages,
targetedMessages,
agentManager: {
subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => {
agentEventListeners.push(listener);
return () => {};
}),
},
});
const legacySocket = {};
const selectiveSocket = {};
session.updateClientCapabilities(null, legacySocket);
session.updateClientCapabilities({ selective_agent_timeline: true }, selectiveSocket);
await session.handleMessage(
{
type: "agent.timeline.set_subscription.request",
agentIds: ["viewed-agent"],
requestId: "timeline-subscription-selective",
},
selectiveSocket,
);
targetedMessages.length = 0;
const listener = agentEventListeners[0];
if (!listener) throw new Error("Agent event listener was not installed");
listener({
type: "agent_stream",
agentId: "not-viewed-agent",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-global", text: "global" },
},
});
expect(messages).toEqual([]);
expect(targetedMessages).toEqual([
{
source: legacySocket,
message: expect.objectContaining({
type: "agent_stream",
payload: expect.objectContaining({ agentId: "not-viewed-agent" }),
}),
},
]);
});
describe("agent config setters", () => {
test("set_agent_mode_request: success emits accepted response carrying the notice", async () => {
const messages: SessionOutboundMessage[] = [];

View File

@@ -429,6 +429,7 @@ export interface SessionOptions {
appVersion?: string | null;
clientCapabilities?: Record<string, unknown> | null;
onMessage: (msg: SessionOutboundMessage) => void;
onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void;
onBinaryMessage?: (frame: Uint8Array) => void;
getTransportBufferedAmount?: () => number | null;
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
@@ -558,6 +559,9 @@ export class Session {
private clientCapabilities: ReadonlySet<ClientCapability>;
private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onMessageToSource:
| ((source: object, msg: SessionOutboundMessage) => void)
| null;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
private readonly getTransportBufferedAmount: () => number | null;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
@@ -583,6 +587,10 @@ export class Session {
private readonly daemonConfigStore: DaemonConfigStore;
private readonly pushTokenStore: PushTokenStore;
private unsubscribeAgentEvents: (() => void) | null = null;
private viewedTimelineAgentIds = new Set<string>();
private readonly viewedTimelineAgentIdsBySource = new Map<object, Set<string>>();
private readonly selectiveTimelineCapabilityBySource = new Map<object, boolean>();
private readonly defaultTimelineSubscriptionSource = {};
private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null;
private readonly agentUpdates: AgentUpdatesService;
private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null;
@@ -625,6 +633,7 @@ export class Session {
appVersion,
clientCapabilities,
onMessage,
onMessageToSource,
onBinaryMessage,
getTransportBufferedAmount,
onLifecycleIntent,
@@ -675,6 +684,7 @@ export class Session {
this.clientCapabilities = parseClientCapabilities(clientCapabilities);
this.sessionId = uuidv4();
this.onMessage = onMessage;
this.onMessageToSource = onMessageToSource ?? null;
this.onBinaryMessage = onBinaryMessage ?? null;
this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0);
this.onLifecycleIntent = onLifecycleIntent ?? null;
@@ -963,14 +973,118 @@ export class Session {
}
}
updateClientCapabilities(capabilities: Record<string, unknown> | null): void {
updateClientCapabilities(capabilities: Record<string, unknown> | null, source?: object): void {
this.clientCapabilities = parseClientCapabilities(capabilities);
if (source) {
this.selectiveTimelineCapabilityBySource.set(
source,
this.supports(CLIENT_CAPS.selectiveAgentTimeline),
);
}
if (!source && !this.supports(CLIENT_CAPS.selectiveAgentTimeline)) {
this.viewedTimelineAgentIdsBySource.clear();
this.viewedTimelineAgentIds.clear();
}
}
clearAgentTimelineSubscription(source: object): void {
this.selectiveTimelineCapabilityBySource.delete(source);
if (this.viewedTimelineAgentIdsBySource.delete(source)) {
this.rebuildViewedTimelineAgentIds();
}
}
private replaceAgentTimelineSubscription(source: object | undefined, agentIds: string[]): void {
const subscriptionSource = source ?? this.defaultTimelineSubscriptionSource;
if (agentIds.length === 0) this.viewedTimelineAgentIdsBySource.delete(subscriptionSource);
else this.viewedTimelineAgentIdsBySource.set(subscriptionSource, new Set(agentIds));
this.rebuildViewedTimelineAgentIds();
}
private rebuildViewedTimelineAgentIds(): void {
const viewedAgentIds = new Set<string>();
for (const agentIds of this.viewedTimelineAgentIdsBySource.values()) {
for (const agentId of agentIds) viewedAgentIds.add(agentId);
}
this.viewedTimelineAgentIds = viewedAgentIds;
}
private usesSelectiveTimelineDelivery(): boolean {
if (this.selectiveTimelineCapabilityBySource.size === 0) {
return this.supports(CLIENT_CAPS.selectiveAgentTimeline);
}
for (const capable of this.selectiveTimelineCapabilityBySource.values()) {
if (!capable) return false;
}
return true;
}
private forwardAgentStream(
event: Extract<AgentManagerEvent, { type: "agent_stream" }>,
serializedEvent: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"],
): void {
if (this.selectiveTimelineCapabilityBySource.size === 0 || !this.onMessageToSource) {
if (this.usesSelectiveTimelineDelivery() && 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.usesSelectiveTimelineDelivery() ||
this.viewedTimelineAgentIds.has(event.agentId)
) {
this.emit({
type: "agent_stream",
payload: this.buildAgentStreamPayload(event, serializedEvent),
});
}
return;
}
for (const [source, supportsSelectiveDelivery] of this.selectiveTimelineCapabilityBySource) {
if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") {
this.onMessageToSource(source, {
type: "agent_attention_required",
payload: {
agentId: event.agentId,
reason: serializedEvent.reason,
timestamp: serializedEvent.timestamp,
shouldNotify: serializedEvent.shouldNotify,
...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}),
},
});
continue;
}
if (
supportsSelectiveDelivery &&
!this.viewedTimelineAgentIdsBySource.get(source)?.has(event.agentId)
) {
continue;
}
this.onMessageToSource(source, {
type: "agent_stream",
payload: this.buildAgentStreamPayload(event, serializedEvent),
});
}
}
supports(capability: ClientCapability): boolean {
return this.clientCapabilities.has(capability);
}
supportsForSource(capability: ClientCapability, source: object): boolean {
if (capability === CLIENT_CAPS.selectiveAgentTimeline) {
return this.selectiveTimelineCapabilityBySource.get(source) ?? this.supports(capability);
}
return this.supports(capability);
}
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
await this.workspaceGitObserver.syncObserverForWorkspace(workspace);
}
@@ -979,6 +1093,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 +1395,7 @@ export class Session {
"agent.session.forward_stream",
);
this.emit({
type: "agent_stream",
payload: this.buildAgentStreamPayload(event, serializedEvent),
});
this.forwardAgentStream(event, serializedEvent);
if (event.event.type === "permission_requested") {
this.emit({
@@ -1377,7 +1498,7 @@ export class Session {
/**
* Main entry point for processing session messages
*/
public async handleMessage(msg: SessionInboundMessage): Promise<void> {
public async handleMessage(msg: SessionInboundMessage, source?: object): Promise<void> {
this.inflightRequests++;
if (this.inflightRequests > this.peakInflightRequests) {
this.peakInflightRequests = this.inflightRequests;
@@ -1391,7 +1512,7 @@ export class Session {
"agent.session.inbound",
);
try {
await this.dispatchInboundMessage(msg);
await this.dispatchInboundMessage(msg, source);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.sessionLogger.error({ err }, "Error handling message");
@@ -1429,12 +1550,12 @@ export class Session {
}
}
private async dispatchInboundMessage(msg: SessionInboundMessage): Promise<void> {
private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise<void> {
const promise =
this.dispatchVoiceAndControlMessage(msg) ??
this.dispatchAgentRewindMessage(msg) ??
this.dispatchAgentRelationshipMessage(msg) ??
this.dispatchAgentTimelineMessage(msg) ??
this.dispatchAgentTimelineMessage(msg, source) ??
this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ??
@@ -1516,7 +1637,10 @@ export class Session {
}
}
private dispatchAgentTimelineMessage(msg: SessionInboundMessage): Promise<void> | undefined {
private dispatchAgentTimelineMessage(
msg: SessionInboundMessage,
source?: object,
): Promise<void> | undefined {
switch (msg.type) {
case "fetch_agent_timeline_request":
return this.handleFetchAgentTimelineRequest(msg);
@@ -1524,6 +1648,24 @@ 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 (
source
? (this.selectiveTimelineCapabilityBySource.get(source) ??
this.supports(CLIENT_CAPS.selectiveAgentTimeline))
: this.supports(CLIENT_CAPS.selectiveAgentTimeline)
) {
this.replaceAgentTimelineSubscription(source, agentIds);
}
const response: SessionOutboundMessage = {
type: "agent.timeline.set_subscription.response",
payload: { agentIds, requestId: msg.requestId },
};
if (source && this.onMessageToSource) this.onMessageToSource(source, response);
else this.emit(response);
return undefined;
}
case "agent.fork_context.request":
return this.handleAgentForkContextRequest(msg);
default:
@@ -3967,6 +4109,8 @@ export class Session {
continue;
}
}
const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id;
subscription.lastEmittedByWorkspaceId.set(workspaceId, payload);
this.emit({
type: "workspace_update",
payload,
@@ -4259,6 +4403,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,
@@ -4515,6 +4662,7 @@ export class Session {
"fetch_workspaces_response_ready",
);
const snapshot = this.buildBootstrapSnapshot(payload.entries);
this.seedWorkspaceSubscriptionSnapshot(subscriptionId, request.filter, payload.entries);
this.emit({
type: "fetch_workspaces_response",
@@ -4575,6 +4723,23 @@ export class Session {
return { snapshotByWorkspaceId };
}
private seedWorkspaceSubscriptionSnapshot(
subscriptionId: string | null,
filter: FetchWorkspacesRequestFilter | undefined,
entries: FetchWorkspacesResponseEntry[],
): void {
const subscription = this.workspaceUpdatesSubscription;
if (!subscription) return;
if (subscriptionId && subscription.subscriptionId !== subscriptionId) return;
if (!subscriptionId && !equal(subscription.filter, filter)) return;
for (const entry of entries) {
subscription.lastEmittedByWorkspaceId.set(entry.id, {
kind: "upsert",
workspace: entry,
});
}
}
private async registerWorkspaceForImportedAgent(
workspace: PersistedWorkspaceRecord,
): Promise<void> {
@@ -4661,7 +4826,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 +4911,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

@@ -6568,6 +6568,64 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho
);
});
test("a workspace leaving a filtered subscription after bootstrap emits a removal", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = asTestSession(
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
);
const descriptor = {
id: "ws-buffered",
projectId: "proj-buffered",
projectDisplayName: "repo",
projectRootPath: REPO_CWD,
workspaceDirectory: REPO_CWD,
projectKind: "git" as const,
workspaceKind: "local_checkout" as const,
name: "repo work",
status: "done" as const,
activityAt: null,
diffStat: null,
};
let currentDescriptor: typeof descriptor | null = descriptor;
let finishListing: (result: ListFetchResult) => void = () => {};
const listing = new Promise<ListFetchResult>((resolve) => {
finishListing = resolve;
});
session.listFetchWorkspacesEntries = async () => listing;
session.buildWorkspaceDescriptorMap = async () =>
new Map(currentDescriptor ? [[currentDescriptor.id, currentDescriptor]] : []);
session.reconcileAndEmitWorkspaceUpdates = async () => undefined;
const bootstrap = session.handleMessage({
type: "fetch_workspaces_request",
requestId: "req-buffered-filter",
filter: { query: "repo" },
subscribe: { subscriptionId: "sub-buffered-filter" },
});
await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id], { skipReconcile: true });
finishListing({
entries: [],
emptyProjects: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
});
await bootstrap;
expect(filterByType(emitted, "workspace_update")).toEqual([
{ type: "workspace_update", payload: { kind: "upsert", workspace: descriptor } },
]);
emitted.length = 0;
currentDescriptor = { ...descriptor, name: "other work" };
await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id], { skipReconcile: true });
expect(filterByType(emitted, "workspace_update")).toEqual([
{
type: "workspace_update",
payload: { kind: "remove", id: descriptor.id },
},
]);
});
test("project.rename.request stores customName and emits an updated workspace descriptor", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = asTestSession(
@@ -7827,4 +7885,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,8 @@ function createSessionWithActivity(
) {
return {
getClientActivity: vi.fn(() => activity),
supports: () => false,
supportsForSource: () => false,
};
}

View File

@@ -52,6 +52,10 @@ const sessionMock = vi.hoisted(() => {
handleMessage = vi.fn(async () => {});
handleBinaryFrame = vi.fn((_frame: unknown) => {});
supports = vi.fn((capability: string) => this.args.clientCapabilities?.[capability] === true);
updateClientCapabilities = vi.fn((capabilities: Record<string, unknown> | null) => {
this.args.clientCapabilities = capabilities;
});
clearAgentTimelineSubscription = vi.fn();
getClientActivity = vi.fn(() => null);
getSessionId = vi.fn(() => "mock-session-id");
resetPeakInflight = vi.fn(() => {});

View File

@@ -988,6 +988,12 @@ export class VoiceAssistantWebSocketServer {
}
this.sendToConnection(connection, wrapSessionMessage(msg));
},
onMessageToSource: (source, msg) => {
if (!connection || !connection.sockets.has(source as WebSocketLike)) {
return;
}
this.sendToClient(source as WebSocketLike, wrapSessionMessage(msg));
},
onBinaryMessage: (frame) => {
if (!connection) {
return;
@@ -1093,6 +1099,7 @@ export class VoiceAssistantWebSocketServer {
sockets: new Set([ws]),
externalDisconnectCleanupTimeout: null,
};
session.updateClientCapabilities(clientCapabilities, ws);
return connection;
}
@@ -1163,12 +1170,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, ws);
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 +1291,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,
},
};
}
@@ -1390,6 +1402,7 @@ export class VoiceAssistantWebSocketServer {
this.sessions.delete(ws);
connection.sockets.delete(ws);
connection.session.clearAgentTimelineSubscription(ws);
this.socketIdentities.delete(ws);
if (connection.sockets.size === 0) {
@@ -1758,7 +1771,7 @@ export class VoiceAssistantWebSocketServer {
}
const startMs = performance.now();
await activeConnection.session.handleMessage(message.message);
await activeConnection.session.handleMessage(message.message, ws);
const durationMs = performance.now() - startMs;
this.recordRequestLatency(message.message.type, durationMs);
@@ -2009,21 +2022,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.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, ws)
? {
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);
}