diff --git a/packages/app/e2e/directory-bootstrap.spec.ts b/packages/app/e2e/directory-bootstrap.spec.ts new file mode 100644 index 000000000..ad42b991f --- /dev/null +++ b/packages/app/e2e/directory-bootstrap.spec.ts @@ -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(); + } + }); +}); diff --git a/packages/app/e2e/helpers/daemon-websocket-gate.ts b/packages/app/e2e/helpers/daemon-websocket-gate.ts new file mode 100644 index 000000000..a6c7f3867 --- /dev/null +++ b/packages/app/e2e/helpers/daemon-websocket-gate.ts @@ -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(); + const directoryStarts: DirectoryRequestStartCounts = { + subscribed: { agents: 0, workspaces: 0 }, + unsubscribed: { agents: 0, workspaces: 0 }, + total: { agents: 0, workspaces: 0 }, + }; + const clientRequestCounts = new Map(); + + 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 { + 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; + }, + }; +} diff --git a/packages/app/e2e/helpers/directory-bootstrap-scenario.ts b/packages/app/e2e/helpers/directory-bootstrap-scenario.ts new file mode 100644 index 000000000..97192be9f --- /dev/null +++ b/packages/app/e2e/helpers/directory-bootstrap-scenario.ts @@ -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 { + 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 { + 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>, + ) {} + + static async open(page: Page): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + this.gate.restore(); + await Promise.all(this.workspaces.map((workspace) => workspace.cleanup())); + } + + private async seedWorkspace(prefix: string): Promise { + 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; + } +} diff --git a/packages/app/e2e/viewed-agent-timelines.spec.ts b/packages/app/e2e/viewed-agent-timelines.spec.ts new file mode 100644 index 000000000..0ee56e228 --- /dev/null +++ b/packages/app/e2e/viewed-agent-timelines.spec.ts @@ -0,0 +1,141 @@ +import { expect, type Page } from "@playwright/test"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { test } from "./fixtures"; +import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { + expectReconnectingToastGone, + expectReconnectingToastVisible, +} from "./helpers/workspace-ui"; + +interface ViewedTimelineScenario { + client: SeedDaemonClient; + workspaceId: string; + firstAgentId: string; + secondAgentId: string; + cleanup(): Promise; +} + +async function seedViewedTimelineScenario(): Promise { + const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" }); + const createAgent = (title: string) => + workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title, + modeId: "load-test", + model: "ten-second-stream", + }); + const [firstAgent, secondAgent] = await Promise.all([ + createAgent("First viewed chat"), + createAgent("Second viewed chat"), + ]); + return { + client: workspace.client, + workspaceId: workspace.workspaceId, + firstAgentId: firstAgent.id, + secondAgentId: secondAgent.id, + cleanup: workspace.cleanup, + }; +} + +async function openAgent(page: Page, scenario: ViewedTimelineScenario, agentId: string) { + await page.goto(buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId)); + await page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + ); + await waitForWorkspaceTabsVisible(page); +} + +async function selectAgent(page: Page, title: string) { + await page.getByRole("button", { name: title, exact: true }).click(); +} + +async function moveActiveTabRight(page: Page) { + await page.keyboard.press("Meta+Alt+Shift+ArrowRight"); +} + +async function commitMessage(scenario: ViewedTimelineScenario, agentId: string, prompt: string) { + await scenario.client.sendAgentMessage(agentId, prompt); + const finish = await scenario.client.waitForFinish(agentId, 30_000); + expect(finish.status).toBe("idle"); +} + +test.describe("Viewed agent timelines", () => { + test("a hidden retained chat catches up when shown", async ({ page }) => { + const scenario = await seedViewedTimelineScenario(); + try { + await openAgent(page, scenario, scenario.firstAgentId); + await selectAgent(page, "Second viewed chat"); + await commitMessage( + scenario, + scenario.firstAgentId, + "Committed while the first chat is hidden.", + ); + await expect( + page.getByText("Committed while the first chat is hidden.", { exact: true }), + ).toHaveCount(0); + await selectAgent(page, "First viewed chat"); + await expect( + page.getByText("Committed while the first chat is hidden.", { exact: true }), + ).toBeVisible(); + } finally { + await scenario.cleanup(); + } + }); + + test("two visible split chats both stay current", async ({ page }) => { + const scenario = await seedViewedTimelineScenario(); + try { + await openAgent(page, scenario, scenario.firstAgentId); + await page.getByRole("button", { name: "Split pane right" }).click(); + await selectAgent(page, "Second viewed chat"); + await moveActiveTabRight(page); + await expect( + page.getByRole("button", { name: "First viewed chat", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect( + page.getByRole("button", { name: "Second viewed chat", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(2); + await commitMessage(scenario, scenario.firstAgentId, "First visible pane update."); + await expect(page.getByText("First visible pane update.", { exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Second viewed chat", exact: true }), + ).toBeVisible(); + } finally { + await scenario.cleanup(); + } + }); + + test("a visible chat catches up after reconnecting", async ({ page }) => { + const gate = await installDaemonWebSocketGate(page); + const scenario = await seedViewedTimelineScenario(); + try { + await openAgent(page, scenario, scenario.firstAgentId); + await expect(page.getByRole("button", { name: "First viewed chat" })).toHaveAttribute( + "aria-selected", + "true", + ); + await gate.drop(); + await expectReconnectingToastVisible(page); + await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects."); + await expect( + page.getByText("Committed while the chat reconnects.", { exact: true }), + ).toHaveCount(0); + gate.restore(); + await expectReconnectingToastGone(page); + const recoveredMessage = page.getByText("Committed while the chat reconnects.", { + exact: true, + }); + await expect(recoveredMessage).toHaveCount(1); + await expect(recoveredMessage).toBeVisible(); + } finally { + gate.restore(); + await scenario.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index d1fccbdc3..62b18f6ee 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -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(); - - 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 { - 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 }); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index b03702a81..55d089846 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -6,7 +6,10 @@ import { useTranslation } from "react-i18next"; import { useClientActivity } from "@/hooks/use-client-activity"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; -import { refreshAgentInitializationTimeout } from "@/hooks/use-agent-initialization"; +import { + createSetAgentInitializing, + refreshAgentInitializationTimeout, +} from "@/hooks/use-agent-initialization"; import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { generateMessageId, type StreamItem } from "@/types/stream"; import { @@ -16,12 +19,8 @@ import { type TimelineReducerSideEffect, } from "@/timeline/session-stream-reducers"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { - isTimelineCatchUpComplete, - planResumeTimelineSync, - planTimelineCatchUpAfter, - planTimelineCatchUpFollowUp, -} from "@/timeline/timeline-sync-plan"; +import { isTimelineCatchUpComplete } from "@/timeline/timeline-sync-plan"; +import { createViewedTimelineSync, type ViewedTimelineSync } from "@/timeline/viewed-timeline-sync"; import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages"; import { @@ -29,7 +28,6 @@ import { type AgentAttentionNotificationPayload, type NotificationPermissionRequest, } from "@getpaseo/protocol/agent-attention-notification"; -import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types"; import type { GitSetupOptions } from "@getpaseo/protocol/messages"; @@ -54,25 +52,20 @@ import { getIsAppActivelyVisible } from "@/utils/app-visibility"; import { getInitKey, getInitDeferred, + createInitDeferred, resolveInitDeferred, rejectInitDeferred, } from "@/utils/agent-initialization"; import { encodeImages } from "@/utils/encode-images"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; -import { resolveProjectPlacement } from "@/utils/project-placement"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; -import { - resolveComposerAttachmentSubmitFormat, - splitComposerAttachmentsForSubmit, -} from "@/composer/attachments/submit"; -import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; import { clearWorkspaceArchivePending, shouldSuppressWorkspaceForLocalArchive, } from "@/contexts/session-workspace-upserts"; -import { isNative } from "@/constants/platform"; +import { reconcileWorkspaceDirectory } from "@/contexts/workspace-directory-reconciliation"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; @@ -97,7 +90,6 @@ export type { } from "@/stores/session-store"; const HISTORY_STALE_AFTER_MS = 60_000; -const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300; function hasAgentUsageChanged( incomingUsage: Agent["lastUsage"] | undefined, @@ -127,15 +119,29 @@ interface BufferedAudioChunk { interface WorkspaceHydrationSnapshot { workspaces: Map; emptyProjects: Map; + deltas: readonly WorkspaceUpdatePayload[]; } +interface WorkspaceHydrationTransaction { + id: symbol; + client: DaemonClient; + workspaces: Map; + deltas: WorkspaceUpdatePayload[]; +} + +type WorkspaceUpdatePayload = Extract< + SessionOutboundMessage, + { type: "workspace_update" } +>["payload"]; + async function fetchWorkspaceHydrationSnapshot(input: { client: DaemonClient; serverId: string; subscribe: boolean; isCancelled?: () => boolean; + transaction: WorkspaceHydrationTransaction; + isCurrent: () => boolean; }): Promise { - const workspaces = new Map(); const emptyProjects = new Map(); let cursor: string | null = null; let includeSubscribe = input.subscribe; @@ -146,7 +152,7 @@ async function fetchWorkspaceHydrationSnapshot(input: { ...(includeSubscribe ? { subscribe: {} } : {}), page: cursor ? { limit: 200, cursor } : { limit: 200 }, }); - if (input.isCancelled?.()) { + if (input.isCancelled?.() || !input.isCurrent()) { return null; } @@ -155,7 +161,7 @@ async function fetchWorkspaceHydrationSnapshot(input: { if (shouldSuppressWorkspaceForLocalArchive({ serverId: input.serverId, workspace })) { continue; } - workspaces.set(workspace.id, workspace); + input.transaction.workspaces.set(workspace.id, workspace); } // Project parents with no active workspaces only ride on the first page. @@ -171,7 +177,11 @@ async function fetchWorkspaceHydrationSnapshot(input: { includeSubscribe = false; } - return { workspaces, emptyProjects }; + return { + workspaces: new Map(input.transaction.workspaces), + emptyProjects, + deltas: [...input.transaction.deltas], + }; } function decodeBase64Chunk(base64: string): Uint8Array { @@ -240,54 +250,11 @@ const getLatestPermissionRequest = ( return null; }; -type AgentUpdatePayload = Extract["payload"]; type WorkspaceSetupProgressPayload = Extract< SessionOutboundMessage, { type: "workspace_setup_progress" } >["payload"]; -const getAgentIdFromUpdate = (update: AgentUpdatePayload): string => - update.kind === "remove" ? update.agentId : update.agent.id; - -// --------------------------------------------------------------------------- -// Module-level pending agent updates buffer (scoped by serverId) -// --------------------------------------------------------------------------- -const pendingAgentUpdates = new Map(); - -function pendingKey(serverId: string, agentId: string): string { - return `${serverId}:${agentId}`; -} - -export function bufferPendingAgentUpdate( - serverId: string, - agentId: string, - update: AgentUpdatePayload, -): void { - pendingAgentUpdates.set(pendingKey(serverId, agentId), update); -} - -export function flushPendingAgentUpdate( - serverId: string, - agentId: string, -): AgentUpdatePayload | undefined { - const key = pendingKey(serverId, agentId); - const update = pendingAgentUpdates.get(key); - pendingAgentUpdates.delete(key); - return update; -} - -export function deletePendingAgentUpdate(serverId: string, agentId: string): void { - pendingAgentUpdates.delete(pendingKey(serverId, agentId)); -} - -export function clearPendingAgentUpdates(serverId: string): void { - for (const key of Array.from(pendingAgentUpdates.keys())) { - if (key.startsWith(`${serverId}:`)) { - pendingAgentUpdates.delete(key); - } - } -} - type SessionStoreActions = ReturnType; type SetInitializingAgents = SessionStoreActions["setInitializingAgents"]; type SetAgentStreamTail = SessionStoreActions["setAgentStreamTail"]; @@ -401,20 +368,12 @@ function applyTimelineStreamPatches(input: { function executeTimelineSideEffects(input: { sideEffects: TimelineReducerSideEffect[]; agentId: string; - serverId: string; - requestCanonicalCatchUp: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; - applyAgentUpdatePayload: (payload: AgentUpdatePayload) => void; + recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; }): void { - const { sideEffects, agentId, serverId, requestCanonicalCatchUp, applyAgentUpdatePayload } = - input; + const { sideEffects, agentId, recoverTimelineGap } = input; for (const effect of sideEffects) { if (effect.type === "catch_up") { - requestCanonicalCatchUp(agentId, effect.cursor); - } else if (effect.type === "flush_pending_updates") { - const deferredUpdate = flushPendingAgentUpdate(serverId, agentId); - if (deferredUpdate) { - applyAgentUpdatePayload(deferredUpdate); - } + recoverTimelineGap(agentId, effect.cursor); } } } @@ -553,9 +512,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity); const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const clearDraftInput = useDraftStore((state) => state.clearDraftInput); - const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const updateSessionClient = useSessionStore((state) => state.updateSessionClient); const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); + const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync); const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress); const removeWorkspaceSetup = useWorkspaceSetupStore((state) => state.removeWorkspace); const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer); @@ -567,32 +526,45 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const focusedTerminalId = useSessionStore( (state) => state.sessions[serverId]?.focusedTerminalId ?? null, ); - const sessionAgents = useSessionStore((state) => state.sessions[serverId]?.agents); - - const previousAgentStatusRef = useRef>(new Map()); - const sendAgentMessageRef = useRef< - | (( - agentId: string, - message: string, - images?: AttachmentMetadata[], - attachments?: AgentAttachment[], - ) => Promise) - | null - >(null); const _sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); - const revalidationTimerRef = useRef | null>(null); - const revalidationInFlightRef = useRef | null>(null); - const revalidationQueuedRef = useRef(false); - const timelineCatchUpInFlightRef = useRef>(new Set()); - const wasConnectedRef = useRef(isConnected); + const viewedTimelineSyncRef = useRef(null); const audioOutputBuffersRef = useRef>(new Map()); const activeAudioGroupsRef = useRef>(new Set()); + const workspaceHydrationRef = useRef(null); + + const applyWorkspaceUpdatePayload = useCallback( + (payload: WorkspaceUpdatePayload) => { + if (payload.kind === "remove") { + clearWorkspaceArchivePending({ serverId, workspaceId: payload.id }); + removeWorkspaceSetup({ serverId, workspaceId: payload.id }); + removeWorkspace(serverId, payload.id); + if (payload.emptyProject) { + addEmptyProject(serverId, normalizeEmptyProjectDescriptor(payload.emptyProject)); + } + if (payload.removedProjectId) removeEmptyProject(serverId, payload.removedProjectId); + return; + } + const workspace = normalizeWorkspaceDescriptor(payload.workspace); + if (!shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) { + mergeWorkspaces(serverId, [workspace]); + } + }, + [ + addEmptyProject, + mergeWorkspaces, + removeEmptyProject, + removeWorkspace, + removeWorkspaceSetup, + serverId, + ], + ); useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { appStateRef.current = nextState; + viewedTimelineSyncRef.current?.setActive(getIsAppActivelyVisible(nextState)); }); return () => { @@ -600,26 +572,35 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }; }, []); - useEffect(() => { - previousAgentStatusRef.current = reconcilePreviousAgentStatuses( - previousAgentStatusRef.current, - sessionAgents, - ); - }, [sessionAgents]); - const hydrateWorkspaces = useCallback( async (options?: { subscribe?: boolean; isCancelled?: () => boolean }) => { if (!client || !isConnected) { return; } - const snapshot = await fetchWorkspaceHydrationSnapshot({ + const transaction: WorkspaceHydrationTransaction = { + id: Symbol("workspace hydration"), client, - serverId, - subscribe: options?.subscribe ?? false, - isCancelled: options?.isCancelled, - }); + workspaces: new Map(), + deltas: [], + }; + workspaceHydrationRef.current = transaction; + let snapshot: WorkspaceHydrationSnapshot | null; + try { + snapshot = await fetchWorkspaceHydrationSnapshot({ + client, + serverId, + subscribe: options?.subscribe ?? false, + isCancelled: options?.isCancelled, + transaction, + isCurrent: () => workspaceHydrationRef.current?.id === transaction.id, + }); + } catch (error) { + if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null; + throw error; + } if (!snapshot || options?.isCancelled?.()) { + if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null; return; } @@ -631,14 +612,28 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider isCancelled: options?.isCancelled, }); if (didBackfillLegacy) { + if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null; return; } - setWorkspaces(serverId, snapshot.workspaces); + setWorkspaces( + serverId, + reconcileWorkspaceDirectory({ snapshot: snapshot.workspaces, deltas: snapshot.deltas }), + ); setEmptyProjects(serverId, snapshot.emptyProjects.values()); setHasHydratedWorkspaces(serverId, true); + for (const delta of snapshot.deltas) applyWorkspaceUpdatePayload(delta); + if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null; }, - [client, isConnected, serverId, setEmptyProjects, setHasHydratedWorkspaces, setWorkspaces], + [ + applyWorkspaceUpdatePayload, + client, + isConnected, + serverId, + setEmptyProjects, + setHasHydratedWorkspaces, + setWorkspaces, + ], ); const applyAuthoritativeAgentSnapshot = useCallback( @@ -723,168 +718,25 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } return next; }); - - const prevStatus = previousAgentStatusRef.current.get(agent.id); - if (prevStatus === "running" && agent.status !== "running") { - const session = useSessionStore.getState().sessions[serverId]; - const queue = session?.queuedMessages.get(agent.id); - if (queue && queue.length > 0) { - const [next, ...rest] = queue; - if (sendAgentMessageRef.current) { - const supportsForgeSearch = - useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === - true; - const wirePayload = splitComposerAttachmentsForSubmit(next.attachments, { - format: resolveComposerAttachmentSubmitFormat({ - supportsForgeAttachments: supportsForgeSearch, - }), - }); - void sendAgentMessageRef.current( - agent.id, - next.text, - wirePayload.images, - wirePayload.attachments, - ); - } - setQueuedMessages(serverId, (prev) => { - const updated = new Map(prev); - updated.set(agent.id, rest); - return updated; - }); - } - } - - previousAgentStatusRef.current.set(agent.id, agent.status); }, - [ - queryClient, - serverId, - setAgentLastActivity, - setAgents, - setPendingPermissions, - setQueuedMessages, - ], + [queryClient, serverId, setAgentLastActivity, setAgents, setPendingPermissions], ); - const runAuthoritativeRevalidation = useCallback(async () => { - await Promise.all([ - getHostRuntimeStore().refreshAgentDirectory({ serverId }), - hydrateWorkspaces(), - ]); - }, [hydrateWorkspaces, serverId]); - - const flushAuthoritativeRevalidation = useCallback(() => { - if (!client || !isConnected) { - return; - } - if (revalidationInFlightRef.current) { - revalidationQueuedRef.current = true; - return; - } - - const run = runAuthoritativeRevalidation() - .catch((error) => { - console.error("[Session] authoritative revalidation failed", { - serverId, - error, - }); - }) - .finally(() => { - if (revalidationInFlightRef.current === run) { - revalidationInFlightRef.current = null; - } - if (!revalidationQueuedRef.current) { - return; - } - revalidationQueuedRef.current = false; - if (revalidationTimerRef.current) { - clearTimeout(revalidationTimerRef.current); - } - revalidationTimerRef.current = setTimeout(() => { - revalidationTimerRef.current = null; - flushAuthoritativeRevalidation(); - }, AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS); - }); - - revalidationInFlightRef.current = run; - }, [client, isConnected, runAuthoritativeRevalidation, serverId]); - - const scheduleAuthoritativeRevalidation = useCallback(() => { - if (!client || !isConnected) { - return; - } - - revalidationQueuedRef.current = true; - if (revalidationTimerRef.current) { - return; - } - revalidationTimerRef.current = setTimeout(() => { - revalidationTimerRef.current = null; - if (!revalidationQueuedRef.current) { - return; - } - revalidationQueuedRef.current = false; - flushAuthoritativeRevalidation(); - }, AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS); - }, [client, flushAuthoritativeRevalidation, isConnected]); - - const requestCanonicalCatchUp = useCallback( + const recoverTimelineGap = useCallback( (agentId: string, cursor: { epoch: string; endSeq: number }) => { - const request = planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }); - const key = `${agentId}:${request.cursor.epoch}:${request.cursor.seq}`; - const inFlight = timelineCatchUpInFlightRef.current; - if (inFlight.has(key)) { - return; - } - inFlight.add(key); - void client - .fetchAgentTimeline(agentId, request) - .catch((error) => { - console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error); - }) - .finally(() => { - inFlight.delete(key); - }); + viewedTimelineSyncRef.current?.recoverGap(agentId, cursor); }, - [client], + [], ); const handleAppResumed = useCallback( (awayMs: number) => { - scheduleAuthoritativeRevalidation(); - - if (isNative) { - const session = useSessionStore.getState().sessions[serverId]; - const agentId = session?.focusedAgentId; - if (agentId) { - const plan = planResumeTimelineSync({ - cursor: session?.agentTimelineCursor.get(agentId), - }); - if (plan.direction === "after") { - requestCanonicalCatchUp(agentId, { - epoch: plan.cursor.epoch, - endSeq: plan.cursor.seq, - }); - } else { - void client.fetchAgentTimeline(agentId, plan).catch((error) => { - console.warn("[Session] failed to fetch tail timeline on resume", agentId, error); - }); - } - } - } - if (awayMs < HISTORY_STALE_AFTER_MS) { return; } bumpHistorySyncGeneration(serverId); }, - [ - bumpHistorySyncGeneration, - client, - requestCanonicalCatchUp, - scheduleAuthoritativeRevalidation, - serverId, - ], + [bumpHistorySyncGeneration, serverId], ); // Client activity tracking (heartbeat, push token registration) @@ -944,11 +796,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Initialize session in store useEffect(() => { - initializeSession(serverId, client); + const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0; + initializeSession(serverId, client, generation); }, [serverId, client, initializeSession]); useEffect(() => { - updateSessionClient(serverId, client); + const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0; + updateSessionClient(serverId, client, generation); }, [serverId, client, updateSessionClient]); useEffect(() => { @@ -1024,7 +878,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider useEffect(() => { if (!isConnected) { flushAgentLastActivity(); - clearPendingAgentUpdates(serverId); setInitializingAgents(serverId, new Map()); } }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); @@ -1051,85 +904,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }; }, [client, hydrateWorkspaces, isConnected]); - const applyAgentUpdatePayload = useCallback( - (update: AgentUpdatePayload) => { - if (update.kind === "remove") { - const agentId = update.agentId; - previousAgentStatusRef.current.delete(agentId); - deletePendingAgentUpdate(serverId, agentId); - clearArchiveAgentPending({ queryClient, serverId, agentId }); - - setAgents(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - - setPendingPermissions(serverId, (prev) => { - if (prev.size === 0) { - return prev; - } - let changed = false; - const next = new Map(prev); - for (const [key, pending] of Array.from(next.entries())) { - if (pending.agentId === agentId) { - next.delete(key); - changed = true; - } - } - return changed ? next : prev; - }); - - setQueuedMessages(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - - setAgentTimelineCursor(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - setAgentAuthoritativeHistoryApplied(serverId, agentId, false); - return; - } - - const normalized = normalizeAgentSnapshot(update.agent, serverId); - const agent = applyLegacyDaemonWorkspaceOwnership({ - serverId, - agent: { - ...normalized, - projectPlacement: resolveProjectPlacement({ - projectPlacement: update.project, - cwd: normalized.cwd, - }), - }, - }); - - applyAuthoritativeAgentSnapshot(agent); - }, - [ - applyAuthoritativeAgentSnapshot, - queryClient, - serverId, - setAgentAuthoritativeHistoryApplied, - setAgents, - setAgentTimelineCursor, - setPendingPermissions, - setQueuedMessages, - ], - ); - const applyWorkspaceSetupProgress = useCallback( (payload: WorkspaceSetupProgressPayload) => { upsertWorkspaceSetupProgress({ serverId, payload }); @@ -1222,34 +996,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider executeTimelineSideEffects({ sideEffects: result.sideEffects, agentId, - serverId, - requestCanonicalCatchUp, - applyAgentUpdatePayload, + recoverTimelineGap, }); - const followUp = planTimelineCatchUpFollowUp({ - direction: payload.direction, - hasNewer: payload.hasNewer, - endCursor: payload.endCursor, - error: payload.error, - }); - if (followUp?.direction === "after") { - refreshAgentInitializationTimeout({ - key: initKey, - agentId, - setAgentInitializing: (id, initializing) => { - if (initializing) { - return; - } - clearAgentInitializingFlag(setInitializingAgents, serverId, id); - }, - }); - requestCanonicalCatchUp(agentId, { - epoch: followUp.cursor.epoch, - endSeq: followUp.cursor.seq, - }); - } - finalizeTimelineApplication({ result, agentId, @@ -1263,10 +1012,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }, [ applyAuthoritativeAgentSnapshot, - applyAgentUpdatePayload, clearAgentStreamHead, markAgentHistorySynchronized, - requestCanonicalCatchUp, + recoverTimelineGap, serverId, setAgentAuthoritativeHistoryApplied, setAgentStreamHead, @@ -1278,54 +1026,67 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ); useEffect(() => { - if (isConnected) { - return; - } - clearPendingAgentUpdates(serverId); - }, [isConnected, serverId]); + const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents); + const sync = createViewedTimelineSync({ + setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), + readCursor: (agentId) => + useSessionStore.getState().sessions[serverId]?.agentTimelineCursor.get(agentId), + fetchPage: async (agentId, request) => { + const session = useSessionStore.getState().sessions[serverId]; + const initKey = getInitKey(serverId, agentId); + if ( + session?.agentAuthoritativeHistoryApplied.get(agentId) !== true && + !getInitDeferred(initKey) + ) { + createInitDeferred(initKey, request.direction ?? "tail"); + refreshAgentInitializationTimeout({ + key: initKey, + agentId, + setAgentInitializing, + }); + setAgentInitializing(agentId, true); + } + try { + return await client.fetchAgentTimeline(agentId, request); + } catch (error) { + setAgentInitializing(agentId, false); + rejectInitDeferred(initKey, error instanceof Error ? error : new Error(String(error))); + throw error; + } + }, + reportError: (error) => { + console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); + }, + scheduleRetry: (retry) => { + const timeout = setTimeout(retry, 1_000); + return () => clearTimeout(timeout); + }, + }); + viewedTimelineSyncRef.current = sync; + setViewedTimelineSync(serverId, sync); + sync.setActive(getIsAppActivelyVisible(appStateRef.current)); - useEffect(() => { - const wasConnected = wasConnectedRef.current; - wasConnectedRef.current = isConnected; - if (!wasConnected && isConnected) { - scheduleAuthoritativeRevalidation(); - } - }, [isConnected, scheduleAuthoritativeRevalidation]); - - useEffect(() => { return () => { - if (revalidationTimerRef.current) { - clearTimeout(revalidationTimerRef.current); + if (viewedTimelineSyncRef.current === sync) { + viewedTimelineSyncRef.current = null; } + setViewedTimelineSync(serverId, null); + sync.dispose(); }; - }, []); + }, [client, serverId, setInitializingAgents, setViewedTimelineSync]); + + useEffect(() => { + viewedTimelineSyncRef.current?.setConnected(isConnected); + }, [isConnected]); // Daemon message handlers - directly update Zustand store useEffect(() => { - const unsubAgentUpdate = client.on("agent_update", (message) => { - if (message.type !== "agent_update") return; - const update = message.payload; - const agentId = getAgentIdFromUpdate(update); - const initKey = getInitKey(serverId, agentId); - const session = useSessionStore.getState().sessions[serverId]; - const isSyncingHistory = - session?.initializingAgents.get(agentId) === true && Boolean(getInitDeferred(initKey)); - - if (isSyncingHistory) { - bufferPendingAgentUpdate(serverId, agentId, update); - return; - } - - deletePendingAgentUpdate(serverId, agentId); - applyAgentUpdatePayload(update); - }); - const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({ serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, - requestCanonicalCatchUp, + recoverTimelineGap, }); const unsubAgentStream = client.on("agent_stream", (message) => { @@ -1342,18 +1103,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider voiceRuntime?.onTurnEvent(serverId, agentId, event.type); } - // Attention notification stays in React (not extractable to pure reducer) - if (event.type === "attention_required") { - if (event.shouldNotify) { - notifyAgentAttention({ - agentId, - reason: event.reason, - timestamp: event.timestamp, - notification: event.notification, - }); - } - } - agentStreamReducerQueue.enqueue(agentId, { event: streamEvent, seq, @@ -1366,6 +1115,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // on status changes, which is sufficient for sorting and display purposes. }); + const unsubAgentAttention = client.onAgentAttentionRequired((notification) => { + if (notification.shouldNotify) { + notifyAgentAttention(notification); + } + }); + const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => { if (message.type !== "fetch_agent_timeline_response") return; agentStreamReducerQueue.flushAgent(message.payload.agentId); @@ -1379,26 +1134,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubWorkspaceUpdate = client.on("workspace_update", (message) => { if (message.type !== "workspace_update") return; - if (message.payload.kind === "remove") { - clearWorkspaceArchivePending({ - serverId, - workspaceId: message.payload.id, - }); - removeWorkspaceSetup({ serverId, workspaceId: message.payload.id }); - removeWorkspace(serverId, message.payload.id); - if (message.payload.emptyProject) { - addEmptyProject(serverId, normalizeEmptyProjectDescriptor(message.payload.emptyProject)); - } - if (message.payload.removedProjectId) { - removeEmptyProject(serverId, message.payload.removedProjectId); - } + const hydration = workspaceHydrationRef.current; + if (hydration) { + hydration.deltas.push(message.payload); return; } - const workspace = normalizeWorkspaceDescriptor(message.payload.workspace); - if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) { - return; - } - mergeWorkspaces(serverId, [workspace]); + applyWorkspaceUpdatePayload(message.payload); }); const unsubScriptStatusUpdate = client.on("script_status_update", (message) => { @@ -1667,7 +1408,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return; } const { agentId } = message.payload; - deletePendingAgentUpdate(serverId, agentId); clearArchiveAgentPending({ queryClient, serverId, agentId }); setAgents(serverId, (prev) => { @@ -1780,10 +1520,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); return () => { - unsubAgentUpdate(); unsubAgentStream(); unsubAgentTimeline(); unsubProviderSubagentUpdate(); + unsubAgentAttention(); unsubWorkspaceUpdate(); unsubScriptStatusUpdate(); unsubCheckoutStatusUpdate(); @@ -1827,8 +1567,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setHasHydratedAgents, clearDraftInput, notifyAgentAttention, - requestCanonicalCatchUp, - applyAgentUpdatePayload, + recoverTimelineGap, + applyWorkspaceUpdatePayload, applyWorkspaceSetupProgress, applyTimelineResponse, updateSessionServerInfo, @@ -1837,67 +1577,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider voiceAudioEngine, ]); - const sendAgentMessage = useCallback( - async ( - agentId: string, - message: string, - images?: AttachmentMetadata[], - attachments?: AgentAttachment[], - ) => { - const messageId = generateMessageId(); - const userMessage: StreamItem = { - kind: "user_message", - id: messageId, - text: message, - timestamp: new Date(), - optimistic: true, - ...(images && images.length > 0 ? { images } : {}), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }; - - // Append to head if streaming (keeps the user message with the current - // turn so late text_deltas still find the existing assistant_message). - // Otherwise append to tail. - const currentHead = useSessionStore - .getState() - .sessions[serverId]?.agentStreamHead?.get(agentId); - if (currentHead && currentHead.length > 0) { - setAgentStreamHead(serverId, (prev) => { - const head = prev.get(agentId) || []; - const updated = new Map(prev); - updated.set(agentId, [...head, userMessage]); - return updated; - }); - } else { - setAgentStreamTail(serverId, (prev) => { - const currentStream = prev.get(agentId) || []; - const updated = new Map(prev); - updated.set(agentId, [...currentStream, userMessage]); - return updated; - }); - } - - const imagesData = await encodeImages(images); - if (!client) { - console.warn("[Session] sendAgentMessage skipped: daemon unavailable"); - return; - } - void client - .sendAgentMessage(agentId, message, { - messageId, - ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }) - .catch((error) => { - console.error("[Session] Failed to send agent message:", error); - }); - }, - [serverId, client, setAgentStreamTail, setAgentStreamHead], - ); - - // Keep the ref updated so the agent_update handler can call it - sendAgentMessageRef.current = sendAgentMessage; - const _cancelAgentRun = useCallback( (agentId: string) => { if (!client) { diff --git a/packages/app/src/contexts/workspace-directory-reconciliation.test.ts b/packages/app/src/contexts/workspace-directory-reconciliation.test.ts new file mode 100644 index 000000000..a9d6d8508 --- /dev/null +++ b/packages/app/src/contexts/workspace-directory-reconciliation.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from "vitest"; +import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; +import { normalizeWorkspaceDescriptor } from "@/stores/session-store"; +import { reconcileWorkspaceDirectory } from "./workspace-directory-reconciliation"; + +function workspace(id: string, title: string): WorkspaceDescriptorPayload { + return { + id, + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/repo", + workspaceDirectory: `/repo/${id}`, + projectKind: "git", + workspaceKind: "worktree", + name: id, + title, + status: "done", + activityAt: null, + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }; +} + +it("keeps workspace upserts and removals received during later pages", () => { + const result = reconcileWorkspaceDirectory({ + snapshot: new Map([ + ["updated", normalizeWorkspaceDescriptor(workspace("updated", "snapshot"))], + ["removed", normalizeWorkspaceDescriptor(workspace("removed", "snapshot"))], + ]), + deltas: [ + { kind: "upsert", workspace: workspace("updated", "live") }, + { kind: "remove", id: "removed" }, + ], + }); + + expect(Array.from(result.values()).map(({ id, title }) => [id, title])).toEqual([ + ["updated", "live"], + ]); +}); diff --git a/packages/app/src/contexts/workspace-directory-reconciliation.ts b/packages/app/src/contexts/workspace-directory-reconciliation.ts new file mode 100644 index 000000000..2fcb5e3af --- /dev/null +++ b/packages/app/src/contexts/workspace-directory-reconciliation.ts @@ -0,0 +1,20 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; + +type WorkspaceDelta = Extract["payload"]; + +export function reconcileWorkspaceDirectory(input: { + snapshot: ReadonlyMap; + deltas: readonly WorkspaceDelta[]; +}): Map { + const workspaces = new Map(input.snapshot); + for (const delta of input.deltas) { + if (delta.kind === "remove") { + workspaces.delete(delta.id); + } else { + const workspace = normalizeWorkspaceDescriptor(delta.workspace); + workspaces.set(workspace.id, workspace); + } + } + return workspaces; +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index ba7b899a7..b6b9f1ebb 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -26,7 +26,7 @@ import { import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; -import { isNative, isWeb } from "@/constants/platform"; +import { isWeb } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft"; @@ -40,10 +40,7 @@ import { import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; -import { - clearHistorySyncErrorAfterSuccessfulSync, - reconcileMissingAgentStateWithPresentAgent, -} from "@/panels/agent-panel-load-state"; +import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; @@ -782,44 +779,6 @@ function ChatAgentContent({ mode: "translate", }); - const handleHistorySyncFailure = useCallback( - ({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => { - if (agentId) { - console.warn("[AgentPanel] history sync failed", { - origin, - agentId, - error, - }); - } - const message = toErrorMessage(error); - setMissingAgentState((previous) => { - if (previous.kind === "error" && previous.message === message) { - return previous; - } - return { kind: "error", message }; - }); - }, - [agentId], - ); - - const ensureInitializedWithSyncErrorHandling = useCallback( - (origin: "focus" | "entry") => { - if (!agentId) { - return; - } - ensureAgentIsInitialized(agentId) - .then(() => { - setMissingAgentState(clearHistorySyncErrorAfterSuccessfulSync); - return undefined; - }) - .catch((error) => { - handleHistorySyncFailure({ origin, error }); - return undefined; - }); - }, - [agentId, ensureAgentIsInitialized, handleHistorySyncFailure], - ); - useEffect(() => { if (connectionStatus === "online") { if (reconnectToastArmedRef.current) { @@ -840,13 +799,6 @@ function ChatAgentContent({ } }, [connectionStatus, dismissToast, toastApi, t]); - useEffect(() => { - if (!isPaneFocused || !agentId || !isConnected || !hasSession) { - return; - } - ensureInitializedWithSyncErrorHandling("focus"); - }, [agentId, ensureInitializedWithSyncErrorHandling, hasSession, isConnected, isPaneFocused]); - const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); useEffect(() => { @@ -946,27 +898,6 @@ function ChatAgentContent({ streamViewRef.current?.scrollToBottom("message-sent"); }, [agentId]); - useEffect(() => { - if (!agentId) { - return; - } - if (!isConnected || !hasSession) { - return; - } - const shouldSyncOnEntry = needsAuthoritativeSync || isNative; - if (!shouldSyncOnEntry) { - return; - } - - ensureInitializedWithSyncErrorHandling("entry"); - }, [ - agentId, - ensureInitializedWithSyncErrorHandling, - hasSession, - isConnected, - needsAuthoritativeSync, - ]); - useEffect(() => { initAttemptTokenRef.current += 1; setMissingAgentState({ kind: "idle" }); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index f9424e195..f679b1f18 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1,8 +1,4 @@ import { afterEach, describe, expect, it, vi } from "vitest"; - -vi.hoisted(() => { - Object.defineProperty(globalThis, "__DEV__", { value: false, configurable: true }); -}); import type { DaemonClient, ConnectionState, @@ -10,8 +6,13 @@ import type { FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import type { HostConnection, HostProfile } from "@/types/host-connection"; import { useSessionStore, type Agent } from "@/stores/session-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; +import { queryClient } from "@/data/query-client"; import { HostRuntimeController, HostRuntimeStore, @@ -20,10 +21,6 @@ import { type HostRuntimeStorage, } from "./host-runtime"; -vi.mock("@/browser-automation/handler", () => ({ - mountBrowserAutomationDaemonClientHandler: vi.fn(() => () => undefined), -})); - class FakeDaemonClient { private state: ConnectionState = { status: "idle" }; private listeners = new Set<(status: ConnectionState) => void>(); @@ -33,7 +30,43 @@ class FakeDaemonClient { private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = []; public connectCalls = 0; public fetchAgentsCalls: FetchAgentsOptions[] = []; - public fetchAgentsResponses: Awaited>[] = []; + public fetchAgentsResponses: Array< + Awaited> | ReturnType + > = []; + public sentAgentMessages: Array> = []; + private agentUpdateListeners = new Set< + (message: Extract) => void + >(); + private fetchWaiters = new Set<() => void>(); + private agentListenerWaiters = new Set<() => void>(); + private sentMessageWaiters = new Set<() => void>(); + + on( + type: "agent_update", + listener: (message: Extract) => void, + ): () => void { + if (type === "agent_update") this.agentUpdateListeners.add(listener); + for (const waiter of this.agentListenerWaiters) waiter(); + return () => this.agentUpdateListeners.delete(listener); + } + + async waitForAgentUpdates(): Promise { + if (this.agentUpdateListeners.size > 0) return; + await new Promise((resolve) => { + const waiter = () => { + if (this.agentUpdateListeners.size === 0) return; + this.agentListenerWaiters.delete(waiter); + resolve(); + }; + this.agentListenerWaiters.add(waiter); + }); + } + + agentUpdate(payload: Extract["payload"]): void { + for (const listener of this.agentUpdateListeners) { + listener({ type: "agent_update", payload }); + } + } async connect(): Promise { this.connectCalls += 1; @@ -44,6 +77,23 @@ class FakeDaemonClient { this.setConnectionState({ status: "disconnected", reason: "client_closed" }); } + async sendAgentMessage(...args: Parameters): Promise { + this.sentAgentMessages.push(args); + for (const waiter of this.sentMessageWaiters) waiter(); + } + + async waitForSentMessages(count: number): Promise { + if (this.sentAgentMessages.length >= count) return; + await new Promise((resolve) => { + const waiter = () => { + if (this.sentAgentMessages.length < count) return; + this.sentMessageWaiters.delete(waiter); + resolve(); + }; + this.sentMessageWaiters.add(waiter); + }); + } + ensureConnected(): void { if (this.state.status !== "connected") { this.setConnectionState({ status: "connected" }); @@ -70,9 +120,10 @@ class FakeDaemonClient { options?: FetchAgentsOptions, ): Promise>> { this.fetchAgentsCalls.push(options ?? {}); + for (const waiter of this.fetchWaiters) waiter(); const queued = this.fetchAgentsResponses.shift(); if (queued) { - return queued; + return await queued; } return makeFetchAgentsPayload({ entries: [], @@ -80,6 +131,18 @@ class FakeDaemonClient { }); } + async waitForFetches(count: number): Promise { + if (this.fetchAgentsCalls.length >= count) return; + await new Promise((resolve) => { + const waiter = () => { + if (this.fetchAgentsCalls.length < count) return; + this.fetchWaiters.delete(waiter); + resolve(); + }; + this.fetchWaiters.add(waiter); + }); + } + async ping(): Promise<{ rttMs: number }> { return { rttMs: 0 }; } @@ -160,6 +223,32 @@ function makeFetchAgentsPayload(input: { }; } +class Deferred { + readonly promise: Promise; + private resolvePromise!: (value: T) => void; + + constructor() { + this.promise = new Promise((resolve) => { + this.resolvePromise = resolve; + }); + } + + resolve(value: T): void { + this.resolvePromise(value); + } +} + +async function waitForDirectoryReady(store: HostRuntimeStore, serverId: string): Promise { + if (store.getSnapshot(serverId)?.agentDirectoryStatus === "ready") return; + await new Promise((resolve) => { + const unsubscribe = store.subscribe(serverId, () => { + if (store.getSnapshot(serverId)?.agentDirectoryStatus !== "ready") return; + unsubscribe(); + resolve(); + }); + }); +} + function makeFetchAgentsEntry(input: { id: string; cwd: string; @@ -220,6 +309,14 @@ function makeFetchAgentsEntry(input: { }; } +function replicaAgent(snapshot: FetchAgentsEntry["agent"], serverId: string): Agent { + return { ...normalizeAgentSnapshot(snapshot, serverId), projectPlacement: null }; +} + +function agentPermission(id: string): AgentPermissionRequest { + return { id, provider: "codex", name: id, kind: "tool", title: id }; +} + function makeHost(input?: Partial): HostProfile { const direct: HostConnection = { id: "direct:lan:6767", @@ -1322,13 +1419,11 @@ describe("HostRuntimeStore", () => { useSessionStore .getState() - .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); - const timeoutAt = Date.now() + 200; - while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ @@ -1342,11 +1437,18 @@ describe("HostRuntimeStore", () => { expect(snapshot?.agentDirectoryStatus).toBe("ready"); expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true); + await store.refreshAgentDirectory({ serverId: host.serverId }); + expect(fakeClient.fetchAgentsCalls[1]).toEqual({ + scope: "active", + sort: [{ key: "updated_at", direction: "desc" }], + page: { limit: 200 }, + }); + store.syncHosts([]); useSessionStore.getState().clearSession(host.serverId); }); - it("bootstraps agent directory immediately when connection goes online (no session required)", async () => { + it("waits for the matching session replica before committing the connected client bootstrap", async () => { const host = makeHost({ serverId: "srv_no_session", connections: [ @@ -1373,10 +1475,14 @@ describe("HostRuntimeStore", () => { store.syncHosts([host]); - const timeoutAt = Date.now() + 200; - while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await Promise.resolve(); + expect(fakeClient.fetchAgentsCalls).toEqual([]); + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ @@ -1387,6 +1493,7 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); }); it("bootstraps legacy daemons from unscoped agents and creates path-backed workspaces", async () => { @@ -1428,7 +1535,7 @@ describe("HostRuntimeStore", () => { }); const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.updateSessionServerInfo(host.serverId, { serverId: host.serverId, hostname: null, @@ -1436,14 +1543,8 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([host]); - const timeoutAt = Date.now() + 300; - while ( - (fakeClient.fetchAgentsCalls.length === 0 || - !useSessionStore.getState().sessions[host.serverId]?.workspaces.has("/repo/legacy-app")) && - Date.now() < timeoutAt - ) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); expect(fakeClient.fetchAgentsCalls).toEqual([ { @@ -1466,6 +1567,98 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it("drains legacy snapshot and buffered running transitions exactly once", async () => { + const host = makeHost({ serverId: "srv_legacy_transitions" }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const pageTwo = new Deferred>>(); + const snapshotAgent = makeFetchAgentsEntry({ + id: "legacy-snapshot", + cwd: "/legacy/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + }); + const bufferedAgent = makeFetchAgentsEntry({ + id: "legacy-buffered", + cwd: "/legacy/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + }); + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [ + { ...snapshotAgent, agent: { ...snapshotAgent.agent, status: "idle" } }, + { ...bufferedAgent, agent: { ...bufferedAgent.agent, status: "running" } }, + ], + hasMore: true, + nextCursor: "legacy-page-two", + }), + pageTwo.promise, + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_legacy_transitions", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.updateSessionServerInfo(host.serverId, { + serverId: host.serverId, + hostname: null, + version: "0.1.96", + }); + sessionStore.setAgents( + host.serverId, + new Map([ + [ + "legacy-snapshot", + { ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" }, + ], + [ + "legacy-buffered", + { ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" }, + ], + ]), + ); + sessionStore.setQueuedMessages( + host.serverId, + new Map([ + ["legacy-snapshot", [{ id: "legacy-snapshot-message", text: "snapshot", attachments: [] }]], + ["legacy-buffered", [{ id: "legacy-buffered-message", text: "buffered", attachments: [] }]], + ]), + ); + store.syncHosts([host]); + await fakeClient.waitForFetches(2); + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...bufferedAgent.agent, status: "idle" }, + project: bufferedAgent.project, + }); + pageTwo.resolve(makeFetchAgentsPayload({ entries: [] })); + await waitForDirectoryReady(store, host.serverId); + await fakeClient.waitForSentMessages(2); + + expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([ + ["legacy-snapshot", "snapshot"], + ["legacy-buffered", "buffered"], + ]); + expect( + Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map( + ({ id, status, workspaceId }) => [id, status, workspaceId], + ), + ).toEqual([ + ["legacy-snapshot", "idle", "/legacy/repo"], + ["legacy-buffered", "idle", "/legacy/repo"], + ]); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + it("fetches all pages during bootstrap within the active agent scope", async () => { const host = makeHost({ serverId: "srv_paged", @@ -1521,13 +1714,11 @@ describe("HostRuntimeStore", () => { useSessionStore .getState() - .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); - const timeoutAt = Date.now() + 300; - while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < timeoutAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await fakeClient.waitForFetches(2); + await waitForDirectoryReady(store, host.serverId); expect(fakeClient.fetchAgentsCalls).toHaveLength(2); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ @@ -1542,16 +1733,9 @@ describe("HostRuntimeStore", () => { page: { limit: 200, cursor: "cursor-page-2" }, }); - let staleAgent = + const staleAgent = useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ?? null; - const staleTimeoutAt = Date.now() + 300; - while (!staleAgent && Date.now() < staleTimeoutAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - staleAgent = - useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ?? - null; - } expect(staleAgent?.requiresAttention).toBe(true); expect(staleAgent?.attentionReason).toBe("error"); @@ -1563,6 +1747,444 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it("replays agent updates received while a later bootstrap page is loading", async () => { + const host = makeHost({ serverId: "srv_paged_delta" }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + let finishPageTwo!: (payload: Awaited>) => void; + const pageTwo = new Promise>>((resolve) => { + finishPageTwo = resolve; + }); + const pageOneAgent = makeFetchAgentsEntry({ + id: "agent-a", + cwd: "/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + title: "snapshot", + }); + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [pageOneAgent], + hasMore: true, + nextCursor: "page-two", + subscriptionId: "app:srv_paged_delta", + }), + pageTwo, + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_paged_delta", + }, + }); + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + store.syncHosts([host]); + await fakeClient.waitForFetches(2); + + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...pageOneAgent.agent, title: "live" }, + project: pageOneAgent.project, + }); + finishPageTwo( + makeFetchAgentsPayload({ + entries: [ + makeFetchAgentsEntry({ + id: "agent-b", + cwd: "/repo", + updatedAt: "2026-07-12T09:00:00.000Z", + }), + ], + }), + ); + await waitForDirectoryReady(store, host.serverId); + + expect( + Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map( + (agent) => [agent.id, agent.title], + ), + ).toEqual([ + ["agent-a", "live"], + ["agent-b", null], + ]); + + const agentB = makeFetchAgentsEntry({ + id: "agent-b", + cwd: "/repo", + updatedAt: "2026-07-12T11:00:00.000Z", + title: "immediate", + }); + fakeClient.agentUpdate({ kind: "upsert", agent: agentB.agent, project: agentB.project }); + expect(useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-b")?.title).toBe( + "immediate", + ); + fakeClient.agentUpdate({ kind: "remove", agentId: "agent-b" }); + expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-b")).toBe(false); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("buffers updates until the matching session generation exists", async () => { + const host = makeHost({ serverId: "srv_pre_session" }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const snapshotEntry = makeFetchAgentsEntry({ + id: "agent-pre-session", + cwd: "/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + title: "snapshot", + }); + fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [snapshotEntry] })); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_pre_session", + }, + }); + + store.syncHosts([host]); + await fakeClient.waitForAgentUpdates(); + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...snapshotEntry.agent, title: "before-session" }, + project: snapshotEntry.project, + }); + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); + + expect( + useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-pre-session")?.title, + ).toBe("before-session"); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("rejects a superseded refresh without overwriting the newer replica", async () => { + const host = makeHost({ serverId: "srv_overlap" }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [] })); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_overlap", + }, + }); + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + store.syncHosts([host]); + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); + const olderPage = new Deferred>>(); + fakeClient.fetchAgentsResponses.push(olderPage.promise); + const olderRefresh = store.refreshAgentDirectory({ serverId: host.serverId }); + await fakeClient.waitForFetches(2); + const newerEntry = makeFetchAgentsEntry({ + id: "newer", + cwd: "/repo", + updatedAt: "2026-07-12T11:00:00.000Z", + }); + fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [newerEntry] })); + + const newerRefresh = store.refreshAgentDirectory({ serverId: host.serverId }); + await fakeClient.waitForFetches(3); + await newerRefresh; + olderPage.resolve( + makeFetchAgentsPayload({ + entries: [ + makeFetchAgentsEntry({ + id: "older", + cwd: "/repo", + updatedAt: "2026-07-12T09:00:00.000Z", + }), + ], + }), + ); + await expect(olderRefresh).rejects.toThrow(); + + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...newerEntry.agent, title: "after cleanup" }, + project: newerEntry.project, + }); + + expect( + Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map( + ({ id, title }) => [id, title], + ), + ).toEqual([["newer", "after cleanup"]]); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("rejects a refresh when the session generation changes before commit", async () => { + const host = makeHost({ serverId: "srv_stale_generation" }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const existingEntry = makeFetchAgentsEntry({ + id: "existing", + cwd: "/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + }); + fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [existingEntry] })); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_stale_generation", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + store.syncHosts([host]); + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); + const stalePage = new Deferred>>(); + fakeClient.fetchAgentsResponses.push(stalePage.promise); + + const refresh = store.refreshAgentDirectory({ serverId: host.serverId }); + await fakeClient.waitForFetches(2); + sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 2); + stalePage.resolve( + makeFetchAgentsPayload({ + entries: [ + makeFetchAgentsEntry({ + id: "stale", + cwd: "/repo", + updatedAt: "2026-07-12T11:00:00.000Z", + }), + ], + }), + ); + + await expect(refresh).rejects.toThrow(); + expect( + Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.keys() ?? []), + ).toEqual(["existing"]); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("drains queued messages once for snapshot and buffered running transitions", async () => { + const host = makeHost({ serverId: "srv_queued_transitions" }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const pageTwo = new Deferred>>(); + const snapshotAgent = makeFetchAgentsEntry({ + id: "snapshot-transition", + cwd: "/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + }); + const bufferedAgent = makeFetchAgentsEntry({ + id: "buffered-transition", + cwd: "/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + }); + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [ + { ...snapshotAgent, agent: { ...snapshotAgent.agent, status: "idle" } }, + { ...bufferedAgent, agent: { ...bufferedAgent.agent, status: "running" } }, + ], + hasMore: true, + nextCursor: "page-two", + }), + pageTwo.promise, + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_queued_transitions", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setAgents( + host.serverId, + new Map([ + [ + "snapshot-transition", + { ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" }, + ], + [ + "buffered-transition", + { ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" }, + ], + ]), + ); + sessionStore.setQueuedMessages( + host.serverId, + new Map([ + [ + "snapshot-transition", + [{ id: "message-snapshot", text: "snapshot queued", attachments: [] }], + ], + [ + "buffered-transition", + [{ id: "message-buffered", text: "buffered queued", attachments: [] }], + ], + ]), + ); + store.syncHosts([host]); + await fakeClient.waitForFetches(2); + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...bufferedAgent.agent, status: "idle" }, + project: bufferedAgent.project, + }); + pageTwo.resolve(makeFetchAgentsPayload({ entries: [] })); + await waitForDirectoryReady(store, host.serverId); + await fakeClient.waitForSentMessages(2); + + expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([ + ["snapshot-transition", "snapshot queued"], + ["buffered-transition", "buffered queued"], + ]); + expect( + Array.from(useSessionStore.getState().sessions[host.serverId]?.queuedMessages.values() ?? []), + ).toEqual([[], []]); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("applies buffered stale side effects from the accepted page agent", async () => { + const host = makeHost({ serverId: "srv_buffered_stale_side_effects" }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const pageTwo = new Deferred>>(); + const base = makeFetchAgentsEntry({ + id: "stale-side-effects", + cwd: "/repo", + updatedAt: "2026-07-12T12:00:00.000Z", + title: "newer page", + }); + const pageAgent = { + ...base.agent, + status: "running" as const, + lastUsage: { inputTokens: 10, outputTokens: 5 }, + pendingPermissions: [agentPermission("current-permission")], + }; + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [{ ...base, agent: pageAgent }], + hasMore: true, + nextCursor: "page-two", + }), + pageTwo.promise, + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_buffered_stale_side_effects", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setAgents( + host.serverId, + new Map([ + [ + pageAgent.id, + replicaAgent({ ...pageAgent, updatedAt: "2026-07-12T10:00:00.000Z" }, host.serverId), + ], + ]), + ); + sessionStore.setAgentLastActivity(pageAgent.id, new Date("2026-07-12T12:00:00.000Z")); + sessionStore.flushAgentLastActivity(); + setAgentArchiving({ + queryClient, + serverId: host.serverId, + agentId: pageAgent.id, + isArchiving: true, + }); + store.syncHosts([host]); + await fakeClient.waitForFetches(2); + fakeClient.agentUpdate({ + kind: "upsert", + agent: { + ...pageAgent, + status: "idle", + title: "stale live", + updatedAt: "2026-07-12T11:00:00.000Z", + lastUsage: { inputTokens: 20, outputTokens: 8 }, + pendingPermissions: [agentPermission("stale-permission")], + archivedAt: "2026-07-12T11:00:00.000Z", + }, + project: base.project, + }); + pageTwo.resolve(makeFetchAgentsPayload({ entries: [] })); + await waitForDirectoryReady(store, host.serverId); + sessionStore.flushAgentLastActivity(); + + const state = useSessionStore.getState(); + const agent = state.sessions[host.serverId]?.agents.get(pageAgent.id); + expect({ + title: agent?.title, + status: agent?.status, + usage: agent?.lastUsage, + permissions: Array.from(state.sessions[host.serverId]?.pendingPermissions.values() ?? []).map( + ({ request }) => request.id, + ), + archivePending: isAgentArchiving({ + queryClient, + serverId: host.serverId, + agentId: pageAgent.id, + }), + activity: state.agentLastActivity.get(pageAgent.id)?.toISOString(), + sentMessages: fakeClient.sentAgentMessages.length, + }).toEqual({ + title: "newer page", + status: "running", + usage: { inputTokens: 20, outputTokens: 8 }, + permissions: ["current-permission"], + archivePending: true, + activity: "2026-07-12T12:00:00.000Z", + sentMessages: 0, + }); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + it("re-subscribes agent directory updates after reconnect", async () => { const host = makeHost({ serverId: "srv_resubscribe", @@ -1590,13 +2212,16 @@ describe("HostRuntimeStore", () => { useSessionStore .getState() - .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); - const initialTimeoutAt = Date.now() + 200; - while (fakeClient.fetchAgentsCalls.length < 1 && Date.now() < initialTimeoutAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); + + fakeClient.setConnectionState({ status: "connected" }); + await Promise.resolve(); + await Promise.resolve(); + expect(fakeClient.fetchAgentsCalls).toHaveLength(1); fakeClient.setConnectionState({ status: "disconnected", @@ -1604,10 +2229,8 @@ describe("HostRuntimeStore", () => { }); fakeClient.setConnectionState({ status: "connected" }); - const reconnectTimeoutAt = Date.now() + 200; - while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < reconnectTimeoutAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await fakeClient.waitForFetches(2); + await waitForDirectoryReady(store, host.serverId); expect(fakeClient.fetchAgentsCalls).toEqual([ { @@ -1661,7 +2284,7 @@ describe("HostRuntimeStore", () => { useSessionStore .getState() - .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); useSessionStore.getState().setAgents(host.serverId, () => { const stale = makeFetchAgentsEntry({ id: "agent-archived", @@ -1686,13 +2309,8 @@ describe("HostRuntimeStore", () => { store.syncHosts([host]); - const timeoutAt = Date.now() + 300; - while ( - useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived") && - Date.now() < timeoutAt - ) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived")).toBe( false, diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 283354231..1cf0f46f9 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -40,11 +40,18 @@ import { import { getDesktopHost } from "@/desktop/host"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; -import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync"; -import { useSessionStore } from "@/stores/session-store"; import { - fetchLegacyDaemonWorkspaceDirectory, + applyAgentDirectoryDelta, + replaceFetchedAgentDirectory, + type AgentDirectoryDelta, +} from "@/utils/agent-directory-sync"; +import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { + readLegacyDaemonWorkspaceDirectory, + replaceLegacyDaemonWorkspaceDirectory, shouldUseLegacyDaemonWorkspaceDirectory, + stampLegacyWorkspaceIds, } from "@/workspace/legacy-daemon-workspaces"; import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys"; import { queryClient } from "@/data/query-client"; @@ -54,6 +61,8 @@ import { } from "@/data/push-router"; import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler"; import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules"; +import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; +import { encodeImages } from "@/utils/encode-images"; export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; export type HostRegistryStatus = "loading" | "ready"; @@ -217,10 +226,13 @@ interface AgentDirectoryFetchInput { filter?: FetchAgentsOptions["filter"]; subscribe?: FetchAgentsOptions["subscribe"]; page?: FetchAgentsOptions["page"]; + transaction: AgentDirectoryTransaction; + isCurrent: () => boolean; } interface AgentDirectoryFetchResult { entries: FetchAgentsEntry[]; + deltas: readonly AgentDirectoryDelta[]; subscriptionId: string | null; } @@ -231,7 +243,6 @@ async function fetchCurrentAgentDirectory( let cursor = input.page?.cursor ?? null; let includeSubscribe = true; let subscriptionId: string | null = null; - const entries: FetchAgentsEntry[] = []; while (true) { const payload = await input.client.fetchAgents({ @@ -242,7 +253,8 @@ async function fetchCurrentAgentDirectory( page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit }, }); - entries.push(...payload.entries); + if (!input.isCurrent()) throw new AgentDirectoryRefreshSupersededError(); + input.transaction.entries.push(...payload.entries); subscriptionId = subscriptionId ?? payload.subscriptionId ?? null; includeSubscribe = false; @@ -257,7 +269,11 @@ async function fetchCurrentAgentDirectory( cursor = nextCursor; } - return { entries, subscriptionId }; + return { + entries: input.transaction.entries, + deltas: input.transaction.deltas, + subscriptionId, + }; } function toActiveConnection(connection: HostConnection): ActiveConnection { @@ -1393,6 +1409,28 @@ function rekeyMap(map: Map, oldKey: string, newKey: string): void map.set(newKey, value); } +interface AgentDirectoryTransaction { + id: symbol; + client: DaemonClient; + clientGeneration: number; + entries: FetchAgentsEntry[]; + deltas: AgentDirectoryDelta[]; +} + +interface AgentDirectoryRefreshInput { + serverId: string; + filter?: FetchAgentsOptions["filter"]; + subscribe?: FetchAgentsOptions["subscribe"]; + page?: FetchAgentsOptions["page"]; +} + +interface AgentDirectoryRefreshResult { + agents: ReturnType["agents"]; + subscriptionId: string | null; +} + +class AgentDirectoryRefreshSupersededError extends Error {} + export class HostRuntimeStore { private controllers = new Map(); private serverListeners = new Map void>>(); @@ -1406,6 +1444,11 @@ export class HostRuntimeStore { private deps: HostRuntimeControllerDeps; private lastConnectionStatusByServer = new Map(); private agentDirectoryBootstrapInFlight = new Map>(); + private agentDirectoryTransactions = new Map(); + private agentDirectoryListeners = new Map< + string, + { client: DaemonClient; generation: number; unsubscribe: () => void } + >(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootStarted = false; private storage: HostRuntimeStorage; @@ -1926,6 +1969,9 @@ export class HostRuntimeStore { this.controllers.delete(serverId); this.lastConnectionStatusByServer.delete(serverId); this.agentDirectoryBootstrapInFlight.delete(serverId); + this.agentDirectoryTransactions.delete(serverId); + this.agentDirectoryListeners.get(serverId)?.unsubscribe(); + this.agentDirectoryListeners.delete(serverId); void controller.stop(); this.emit(serverId); } @@ -1980,6 +2026,11 @@ export class HostRuntimeStore { return; } const snapshot = controller.getSnapshot(); + this.installAgentDirectoryListener({ + serverId, + client: snapshot.client, + generation: snapshot.clientGeneration, + }); const previousStatus = this.lastConnectionStatusByServer.get(serverId); this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus); const didTransitionOnline = @@ -2006,14 +2057,11 @@ export class HostRuntimeStore { return; } - const bootstrap = Promise.resolve() - .then(() => - this.refreshAgentDirectory({ - serverId, - subscribe: { subscriptionId: `app:${serverId}` }, - page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT }, - }), - ) + const bootstrap = this.refreshAgentDirectory({ + serverId, + subscribe: { subscriptionId: `app:${serverId}` }, + page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT }, + }) .then(() => undefined) .catch((error) => { console.error("[HostRuntime] agent directory bootstrap failed", { @@ -2031,6 +2079,74 @@ export class HostRuntimeStore { this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap); } + private installAgentDirectoryListener(input: { + serverId: string; + client: DaemonClient | null; + generation: number; + }): void { + const current = this.agentDirectoryListeners.get(input.serverId); + if (current?.client === input.client && current.generation === input.generation) return; + current?.unsubscribe(); + this.agentDirectoryListeners.delete(input.serverId); + this.agentDirectoryTransactions.delete(input.serverId); + if (!input.client) return; + const client = input.client; + const unsubscribe = client.on("agent_update", (message) => { + if (message.type !== "agent_update") return; + const installed = this.agentDirectoryListeners.get(input.serverId); + if (installed?.client !== client || installed.generation !== input.generation) return; + const transaction = this.agentDirectoryTransactions.get(input.serverId); + if (transaction?.client === client && transaction.clientGeneration === input.generation) { + transaction.deltas.push(message.payload); + return; + } + const session = useSessionStore.getState().sessions[input.serverId]; + if (session?.client !== client || session.clientGeneration !== input.generation) return; + this.applyAgentDirectoryDelta(input.serverId, message.payload); + }); + this.agentDirectoryListeners.set(input.serverId, { + client, + generation: input.generation, + unsubscribe, + }); + } + + private applyAgentDirectoryDelta(serverId: string, delta: AgentDirectoryDelta): void { + const result = applyAgentDirectoryDelta({ serverId, delta }); + if (result.stoppedRunning) { + this.drainQueuedAgentMessage(serverId, result.agentId); + } + } + + private drainQueuedAgentMessage(serverId: string, agentId: string): void { + const store = useSessionStore.getState(); + const session = store.sessions[serverId]; + const queue = session?.queuedMessages.get(agentId); + if (!session?.client || !queue?.length) return; + const [next, ...rest] = queue; + const wirePayload = splitComposerAttachmentsForSubmit(next.attachments); + store.setQueuedMessages(serverId, (current) => { + const updated = new Map(current); + updated.set(agentId, rest); + return updated; + }); + void encodeImages(wirePayload.images) + .then((images) => + session.client?.sendAgentMessage(agentId, next.text, { + messageId: next.id, + ...(images && images.length > 0 ? { images } : {}), + attachments: wirePayload.attachments, + }), + ) + .catch((error) => { + console.error("[HostRuntime] failed to drain queued agent message", { + serverId, + agentId, + error: toErrorMessage(error), + }); + }); + } + getSnapshot(serverId: string): HostRuntimeSnapshot | null { return this.controllers.get(serverId)?.getSnapshot() ?? null; } @@ -2095,15 +2211,9 @@ export class HostRuntimeStore { ).then(() => undefined); } - async refreshAgentDirectory(input: { - serverId: string; - filter?: FetchAgentsOptions["filter"]; - subscribe?: FetchAgentsOptions["subscribe"]; - page?: FetchAgentsOptions["page"]; - }): Promise<{ - agents: ReturnType["agents"]; - subscriptionId: string | null; - }> { + async refreshAgentDirectory( + input: AgentDirectoryRefreshInput, + ): Promise { const controller = this.controllers.get(input.serverId); if (!controller) { throw new Error(`Unknown host runtime for serverId ${input.serverId}`); @@ -2114,43 +2224,186 @@ export class HostRuntimeStore { throw new Error(`Host ${input.serverId} is not connected`); } + const transaction: AgentDirectoryTransaction = { + id: Symbol("agent directory refresh"), + client, + clientGeneration: snapshot.clientGeneration, + entries: [], + deltas: [], + }; + this.agentDirectoryTransactions.set(input.serverId, transaction); + + const hasMatchingSession = () => { + const session = useSessionStore.getState().sessions[input.serverId]; + return ( + session?.client === client && session.clientGeneration === transaction.clientGeneration + ); + }; + if (!hasMatchingSession()) { + await new Promise((resolve, reject) => { + const unsubscribeStore = useSessionStore.subscribe((state) => { + const session = state.sessions[input.serverId]; + if ( + session?.client === client && + session.clientGeneration === transaction.clientGeneration + ) { + unsubscribeStore(); + unsubscribeController(); + resolve(); + } + }); + const unsubscribeController = controller.subscribe(() => { + if (controller.getClient() !== client) { + unsubscribeStore(); + unsubscribeController(); + reject(new Error(`Host ${input.serverId} client changed before directory bootstrap`)); + } + }); + }); + } + controller.markAgentDirectorySyncLoading(); try { const session = useSessionStore.getState().sessions[input.serverId]; if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { - const result = await fetchLegacyDaemonWorkspaceDirectory({ + return await this.refreshLegacyAgentDirectory({ + input, + controller, client, - serverId: input.serverId, - subscribe: input.subscribe, - page: input.page, + transaction, + hasMatchingSession, }); - controller.markAgentDirectorySyncReady(); - return { - agents: result.agents, - subscriptionId: result.subscriptionId, - }; } - - const directory = await fetchCurrentAgentDirectory({ + return await this.refreshCurrentAgentDirectory({ + input, + controller, client, - filter: input.filter, - subscribe: input.subscribe, - page: input.page, + transaction, + hasMatchingSession, }); - - const { agents } = replaceFetchedAgentDirectory({ - serverId: input.serverId, - entries: directory.entries, - }); - - controller.markAgentDirectorySyncReady(); - return { - agents, - subscriptionId: directory.subscriptionId, - }; } catch (error) { - controller.markAgentDirectorySyncError(toErrorMessage(error)); + if (!(error instanceof AgentDirectoryRefreshSupersededError)) { + controller.markAgentDirectorySyncError(toErrorMessage(error)); + } throw error; + } finally { + if (this.agentDirectoryTransactions.get(input.serverId)?.id === transaction.id) { + this.agentDirectoryTransactions.delete(input.serverId); + } + } + } + + private async refreshLegacyAgentDirectory(context: { + input: AgentDirectoryRefreshInput; + controller: HostRuntimeController; + client: DaemonClient; + transaction: AgentDirectoryTransaction; + hasMatchingSession: () => boolean; + }): Promise { + const { input, controller, client, transaction, hasMatchingSession } = context; + const directory = await readLegacyDaemonWorkspaceDirectory({ + client, + subscribe: input.subscribe, + page: input.page, + }); + if ( + !directory || + this.agentDirectoryTransactions.get(input.serverId)?.id !== transaction.id || + !hasMatchingSession() + ) { + throw new AgentDirectoryRefreshSupersededError(); + } + const previous = useSessionStore.getState().sessions[input.serverId]?.agents ?? new Map(); + const stampedSnapshot = stampLegacyWorkspaceIds(directory.entries); + const reconciled = reconcileAgentDirectory({ + previous, + snapshot: stampedSnapshot, + deltas: transaction.deltas, + }); + const result = replaceLegacyDaemonWorkspaceDirectory({ + serverId: input.serverId, + entries: reconciled.entries, + }); + this.applyAgentDirectoryCommitSideEffects({ + serverId: input.serverId, + previous, + directory: { + entries: stampedSnapshot, + deltas: transaction.deltas, + subscriptionId: directory.subscriptionId, + }, + stoppedRunningAgentIds: reconciled.stoppedRunningAgentIds, + }); + this.agentDirectoryBootstrapInFlight.delete(input.serverId); + controller.markAgentDirectorySyncReady(); + return { agents: result.agents, subscriptionId: directory.subscriptionId }; + } + + private async refreshCurrentAgentDirectory(context: { + input: AgentDirectoryRefreshInput; + controller: HostRuntimeController; + client: DaemonClient; + transaction: AgentDirectoryTransaction; + hasMatchingSession: () => boolean; + }): Promise { + const { input, controller, client, transaction, hasMatchingSession } = context; + const directory = await fetchCurrentAgentDirectory({ + client, + filter: input.filter, + subscribe: input.subscribe, + page: input.page, + transaction, + isCurrent: () => + this.agentDirectoryTransactions.get(input.serverId)?.id === transaction.id && + controller.getSnapshot().client === client && + controller.getSnapshot().clientGeneration === transaction.clientGeneration, + }); + if ( + this.agentDirectoryTransactions.get(input.serverId)?.id !== transaction.id || + controller.getSnapshot().client !== client || + controller.getSnapshot().clientGeneration !== transaction.clientGeneration || + !hasMatchingSession() + ) { + throw new AgentDirectoryRefreshSupersededError(); + } + const previous = useSessionStore.getState().sessions[input.serverId]?.agents ?? new Map(); + const reconciled = reconcileAgentDirectory({ + previous, + snapshot: directory.entries, + deltas: directory.deltas, + }); + const { agents } = replaceFetchedAgentDirectory({ + serverId: input.serverId, + entries: reconciled.entries, + }); + this.applyAgentDirectoryCommitSideEffects({ + serverId: input.serverId, + previous, + directory, + stoppedRunningAgentIds: reconciled.stoppedRunningAgentIds, + }); + this.agentDirectoryBootstrapInFlight.delete(input.serverId); + controller.markAgentDirectorySyncReady(); + return { agents, subscriptionId: directory.subscriptionId }; + } + + private applyAgentDirectoryCommitSideEffects(input: { + serverId: string; + previous: ReadonlyMap; + directory: AgentDirectoryFetchResult; + stoppedRunningAgentIds: string[]; + }): void { + const snapshotAgentIds = new Set(input.directory.entries.map((entry) => entry.agent.id)); + for (const agentId of input.previous.keys()) { + if (!snapshotAgentIds.has(agentId)) { + applyAgentDirectoryDelta({ serverId: input.serverId, delta: { kind: "remove", agentId } }); + } + } + for (const delta of input.directory.deltas) { + applyAgentDirectoryDelta({ serverId: input.serverId, delta }); + } + for (const agentId of input.stoppedRunningAgentIds) { + this.drainQueuedAgentMessage(input.serverId, agentId); } } diff --git a/packages/app/src/screens/workspace/visible-agent-ids.test.ts b/packages/app/src/screens/workspace/visible-agent-ids.test.ts new file mode 100644 index 000000000..3fbe56119 --- /dev/null +++ b/packages/app/src/screens/workspace/visible-agent-ids.test.ts @@ -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"] }); +}); diff --git a/packages/app/src/screens/workspace/visible-agent-ids.ts b/packages/app/src/screens/workspace/visible-agent-ids.ts new file mode 100644 index 000000000..cfaf4b3eb --- /dev/null +++ b/packages/app/src/screens/workspace/visible-agent-ids.ts @@ -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(); +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index cc585e95a..c7a55aefb 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -89,6 +89,7 @@ import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, } from "@/workspace-tabs/identity"; +import { selectVisibleAgentIds } from "./visible-agent-ids"; import { getHostRuntimeStore, useHostRuntimeClient, @@ -2013,6 +2014,31 @@ function WorkspaceScreenContent({ }), [uiTabs, workspaceLayout], ); + const viewedTimelineSync = useSessionStore( + (state) => state.sessions[normalizedServerId]?.viewedTimelineSync ?? null, + ); + const visibleAgentIds = useMemo( + () => + selectVisibleAgentIds({ + layout: workspaceLayout, + tabs: uiTabs, + routeFocused: isRouteFocused, + focusedPaneOnly: isMobile || isFocusModeEnabled, + }), + [isFocusModeEnabled, isMobile, isRouteFocused, uiTabs, workspaceLayout], + ); + useEffect(() => { + if (!persistenceKey || !viewedTimelineSync) { + return; + } + viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, visibleAgentIds); + }, [persistenceKey, viewedTimelineSync, visibleAgentIds]); + useEffect(() => { + if (!persistenceKey || !viewedTimelineSync) { + return; + } + return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []); + }, [persistenceKey, viewedTimelineSync]); const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId); const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); const focusedPaneAgentId = useMemo(() => { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 480deaa87..ca2e25e5b 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -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()( 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()( ...prev, sessions: { ...prev.sessions, - [serverId]: createInitialSessionState(serverId, client), + [serverId]: createInitialSessionState(serverId, client, clientGeneration), }, }; }); @@ -686,7 +696,7 @@ export const useSessionStore = create()( }); }, - updateSessionClient: (serverId, client) => { + updateSessionClient: (serverId, client, clientGeneration = 0) => { set((prev) => { const session = prev.sessions[serverId]; @@ -694,7 +704,7 @@ export const useSessionStore = create()( return prev; } - if (session.client === client) { + if (session.client === client && session.clientGeneration === clientGeneration) { return prev; } @@ -705,12 +715,29 @@ export const useSessionStore = create()( [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]; diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index aaa0e21c0..c10f91c8b 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1160,7 +1160,7 @@ export interface CreateSessionAgentStreamReducerQueueInput { state: (prev: Map) => Map, ) => void; setAgents: (serverId: string, state: (prev: Map) => Map) => 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); } } }, diff --git a/packages/app/src/timeline/timeline-sync-plan.test.ts b/packages/app/src/timeline/timeline-sync-plan.test.ts index 913f2501b..918c97357 100644 --- a/packages/app/src/timeline/timeline-sync-plan.test.ts +++ b/packages/app/src/timeline/timeline-sync-plan.test.ts @@ -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, ); diff --git a/packages/app/src/timeline/timeline-sync-plan.ts b/packages/app/src/timeline/timeline-sync-plan.ts index 43efec277..d1cd4b376 100644 --- a/packages/app/src/timeline/timeline-sync-plan.ts +++ b/packages/app/src/timeline/timeline-sync-plan.ts @@ -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; diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts new file mode 100644 index 000000000..af5aa8f23 --- /dev/null +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -0,0 +1,410 @@ +import { expect, test } from "vitest"; +import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan"; +import { createViewedTimelineSync } from "./viewed-timeline-sync"; + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: Error): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((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(); + this.memberships.push({ + agentIds, + succeed: () => result.resolve(), + fail: (message) => result.reject(new Error(message)), + }); + this.releaseMembershipWaiter(); + return result.promise; + }, + readCursor: (agentId) => this.cursors.get(agentId), + fetchPage: async (agentId, request) => { + const result = deferred<{ + hasNewer: boolean; + endCursor: { epoch: string; seq: number } | null; + }>(); + this.fetches.push({ + agentId, + request, + respond: ({ hasNewer, seq = 1 }) => + result.resolve({ + hasNewer, + endCursor: { epoch: `epoch-${agentId}`, seq }, + }), + fail: (message) => result.reject(new Error(message)), + }); + this.releaseFetchWaiters(); + return result.promise; + }, + reportError: (error) => { + this.errors.push(error instanceof Error ? error.message : String(error)); + const waiter = this.errorWaiters.shift(); + if (waiter) waiter(this.errors.at(-1) ?? ""); + }, + scheduleRetry: (retry) => { + this.retries.push(retry); + const waiter = this.retryWaiters.shift(); + if (waiter) waiter(this.retries.shift()!); + return () => { + const index = this.retries.indexOf(retry); + if (index >= 0) this.retries.splice(index, 1); + }; + }, + }); + + private readonly memberships: MembershipRequest[] = []; + private readonly membershipWaiters: Array<(request: MembershipRequest) => void> = []; + private readonly fetches: TimelineFetch[] = []; + private readonly fetchWaiters: Array<{ + agentId: string; + resolve(fetch: TimelineFetch): void; + }> = []; + private readonly cursors = new Map(); + private readonly errorWaiters: Array<(message: string) => void> = []; + private readonly retries: Array<() => void> = []; + private readonly retryWaiters: Array<(retry: () => void) => void> = []; + + setCursor(agentId: string, endSeq: number): void { + this.cursors.set(agentId, { epoch: `epoch-${agentId}`, startSeq: 1, endSeq }); + } + + nextMembership(): Promise { + const request = this.memberships.shift(); + if (request) return Promise.resolve(request); + return new Promise((resolve) => this.membershipWaiters.push(resolve)); + } + + nextFetch(agentId: string): Promise { + 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 { + const message = this.errors.at(-1); + if (message) return Promise.resolve(message); + return new Promise((resolve) => this.errorWaiters.push(resolve)); + } + + nextRetry(): Promise<() => void> { + const retry = this.retries.shift(); + if (retry) return Promise.resolve(retry); + return new Promise((resolve) => this.retryWaiters.push(resolve)); + } + + private releaseMembershipWaiter(): void { + const waiter = this.membershipWaiters.shift(); + if (!waiter) return; + const request = this.memberships.shift(); + if (request) waiter(request); + } + + private releaseFetchWaiters(): void { + for (let waiterIndex = this.fetchWaiters.length - 1; waiterIndex >= 0; waiterIndex -= 1) { + const waiter = this.fetchWaiters[waiterIndex]; + const index = this.fetches.findIndex((fetch) => fetch.agentId === waiter.agentId); + if (index < 0) continue; + this.fetchWaiters.splice(waiterIndex, 1); + waiter.resolve(this.fetches.splice(index, 1)[0]); + } + } +} + +test("unchanged visible-set publication does not cancel paged catch-up", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const firstPage = await world.nextFetch("agent-a"); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]); + firstPage.respond({ hasNewer: true, seq: 5 }); + const secondPage = await world.nextFetch("agent-a"); + secondPage.respond({ hasNewer: false }); + + expect(secondPage.request).toEqual({ + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 5 }, + limit: 100, + projection: "projected", + }); + world.expectNoPendingMembership(); +}); + +test("all acknowledged agents begin catch-up independently", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-b", "agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + + const [agentA, agentB] = await Promise.all([ + world.nextFetch("agent-a"), + world.nextFetch("agent-b"), + ]); + agentA.respond({ hasNewer: false }); + agentB.respond({ hasNewer: false }); + + expect(membership.agentIds).toEqual(["agent-a", "agent-b"]); +}); + +test("membership changes during acknowledgement never catch up the stale set", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const staleMembership = await world.nextMembership(); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + staleMembership.succeed(); + const currentMembership = await world.nextMembership(); + currentMembership.succeed(); + const agentB = await world.nextFetch("agent-b"); + agentB.respond({ hasNewer: false }); + + expect({ stale: staleMembership.agentIds, current: currentMembership.agentIds }).toEqual({ + stale: ["agent-a"], + current: ["agent-b"], + }); + world.expectNoPendingFetch(); +}); + +test("removing one agent during paging cancels only that agent", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]); + const initialMembership = await world.nextMembership(); + initialMembership.succeed(); + const [agentA, agentB] = await Promise.all([ + world.nextFetch("agent-a"), + world.nextFetch("agent-b"), + ]); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + const replacement = await world.nextMembership(); + agentA.respond({ hasNewer: true, seq: 4 }); + agentB.respond({ hasNewer: true, seq: 7 }); + const agentBNext = await world.nextFetch("agent-b"); + replacement.succeed(); + agentBNext.respond({ hasNewer: false }); + + expect(replacement.agentIds).toEqual(["agent-b"]); + world.expectNoPendingFetch(); +}); + +test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const firstMembership = await world.nextMembership(); + firstMembership.succeed(); + const stalePage = await world.nextFetch("agent-a"); + + world.sync.setConnected(false); + stalePage.respond({ hasNewer: true, seq: 8 }); + world.sync.setConnected(true); + const restoredMembership = await world.nextMembership(); + restoredMembership.succeed(); + const restoredPage = await world.nextFetch("agent-a"); + restoredPage.respond({ hasNewer: false }); + + expect(restoredMembership.agentIds).toEqual(["agent-a"]); + world.expectNoPendingFetch(); +}); + +test("overlapping sources deduplicate membership and source removal preserves remaining views", async () => { + const world = new TimelineWorld(); + world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]); + world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]); + world.sync.setConnected(true); + const combined = await world.nextMembership(); + combined.succeed(); + const [agentA, agentB] = await Promise.all([ + world.nextFetch("agent-a"), + world.nextFetch("agent-b"), + ]); + agentA.respond({ hasNewer: false }); + agentB.respond({ hasNewer: false }); + + world.sync.replaceVisibleAgentIds("left-route", []); + world.expectNoPendingMembership(); + world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]); + const remaining = await world.nextMembership(); + remaining.succeed(); + + expect({ combined: combined.agentIds, remaining: remaining.agentIds }).toEqual({ + combined: ["agent-a", "agent-b"], + remaining: ["agent-b"], + }); +}); + +test("a failed catch-up reports once and retries through the explicit retry policy", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const failed = await world.nextFetch("agent-a"); + failed.fail("timeline unavailable"); + const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]); + + retryCatchUp(); + const retry = await world.nextFetch("agent-a"); + retry.respond({ hasNewer: false }); + + expect({ error, retryDirection: retry.request.direction }).toEqual({ + error: "timeline unavailable", + retryDirection: "tail", + }); + world.expectNoPendingMembership(); +}); + +test("gap recovery supersedes completed catch-up and pages through the current tail", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const initial = await world.nextFetch("agent-a"); + initial.respond({ hasNewer: false }); + + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + const gapPage = await world.nextFetch("agent-a"); + gapPage.respond({ hasNewer: true, seq: 15 }); + const finalPage = await world.nextFetch("agent-a"); + finalPage.respond({ hasNewer: false }); + + expect([gapPage.request, finalPage.request]).toEqual([ + { + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 10 }, + limit: 100, + projection: "projected", + }, + { + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 15 }, + limit: 100, + projection: "projected", + }, + ]); +}); + +test("membership failure autonomously retries without another visibility declaration", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const failed = await world.nextMembership(); + failed.fail("subscription unavailable"); + const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]); + + retryMembership(); + const retry = await world.nextMembership(); + retry.succeed(); + const catchUp = await world.nextFetch("agent-a"); + catchUp.respond({ hasNewer: false }); + + expect({ error, failed: failed.agentIds, retry: retry.agentIds }).toEqual({ + error: "subscription unavailable", + failed: ["agent-a"], + retry: ["agent-a"], + }); +}); + +test("background sends an empty set and foreground restores visible membership", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const initial = await world.nextMembership(); + initial.succeed(); + const initialCatchUp = await world.nextFetch("agent-a"); + initialCatchUp.respond({ hasNewer: false }); + + world.sync.setActive(false); + const background = await world.nextMembership(); + background.succeed(); + world.sync.setActive(true); + const foreground = await world.nextMembership(); + foreground.succeed(); + const resumedCatchUp = await world.nextFetch("agent-a"); + resumedCatchUp.respond({ hasNewer: false }); + + expect({ background: background.agentIds, foreground: foreground.agentIds }).toEqual({ + background: [], + foreground: ["agent-a"], + }); +}); + +test("stale membership retry cannot overwrite a newer effective set", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const failed = await world.nextMembership(); + failed.fail("subscription unavailable"); + const staleRetry = await world.nextRetry(); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + const current = await world.nextMembership(); + staleRetry(); + current.succeed(); + const catchUp = await world.nextFetch("agent-b"); + catchUp.respond({ hasNewer: false }); + + expect(current.agentIds).toEqual(["agent-b"]); + world.expectNoPendingMembership(); +}); + +test("membership retry cannot run while disconnected", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const failed = await world.nextMembership(); + failed.fail("subscription unavailable"); + const disconnectedRetry = await world.nextRetry(); + + world.sync.setConnected(false); + disconnectedRetry(); + world.expectNoPendingMembership(); + world.sync.setConnected(true); + const restored = await world.nextMembership(); + restored.succeed(); + const catchUp = await world.nextFetch("agent-a"); + catchUp.respond({ hasNewer: false }); + + expect(restored.agentIds).toEqual(["agent-a"]); +}); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts new file mode 100644 index 000000000..58e4b5767 --- /dev/null +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -0,0 +1,289 @@ +import type { AgentTimelineCursorState } from "@/stores/session-store"; +import { + planResumeTimelineSync, + planTimelineCatchUpAfter, + type ProjectedTimelineForwardFetchPlan, +} from "./timeline-sync-plan"; + +interface TimelinePageResult { + hasNewer: boolean; + endCursor: { epoch: string; seq: number } | null; +} + +interface ViewedTimelineSyncPorts { + setSubscription(agentIds: string[]): Promise; + readCursor(agentId: string): AgentTimelineCursorState | undefined; + fetchPage( + agentId: string, + request: ProjectedTimelineForwardFetchPlan, + ): Promise; + reportError(error: unknown): void; + scheduleRetry(retry: () => void): () => void; +} + +export interface ViewedTimelineUiBridge { + replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void; +} + +export interface ViewedTimelineSync extends ViewedTimelineUiBridge { + setActive(active: boolean): void; + setConnected(connected: boolean): void; + recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void; + dispose(): void; +} + +type CatchUpStatus = "running" | "complete" | "error"; + +interface CatchUpState { + generation: number; + status: CatchUpStatus; + cancelRetry?: () => void; +} + +function normalizeAgentIds(agentIds: string[]): string[] { + return [...new Set(agentIds)].filter(Boolean).sort(); +} + +function sameAgentIds(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((agentId, index) => agentId === right[index]); +} + +export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): ViewedTimelineSync { + const sources = new Map(); + const catchUps = new Map(); + const catchUpGenerations = new Map(); + const pendingGaps = new Map(); + 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 => { + if ( + disposed || + !connected || + !isDesired(agentId) || + !isAcknowledged(agentId) || + catchUps.get(agentId)?.generation !== generation + ) { + return; + } + + try { + const page = await ports.fetchPage(agentId, request); + if ( + disposed || + !connected || + !isDesired(agentId) || + !isAcknowledged(agentId) || + catchUps.get(agentId)?.generation !== generation + ) { + return; + } + if (page.hasNewer && page.endCursor) { + await fetchUntilCurrent(agentId, generation, planTimelineCatchUpAfter(page.endCursor)); + return; + } + if (page.hasNewer) { + throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`); + } + catchUps.set(agentId, { generation, status: "complete" }); + } catch (error) { + if (catchUps.get(agentId)?.generation === generation) { + const cancelRetry = ports.scheduleRetry(() => { + const current = catchUps.get(agentId); + if (current?.generation !== generation || current.status !== "error") return; + startCatchUp(agentId); + }); + catchUps.set(agentId, { generation, status: "error", cancelRetry }); + ports.reportError(error); + } + } + }; + + const startCatchUp = ( + agentId: string, + options: { + request?: ProjectedTimelineForwardFetchPlan; + supersede?: boolean; + } = {}, + ) => { + const { request, supersede = false } = options; + if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) { + if (request) pendingGaps.set(agentId, request); + return; + } + const current = catchUps.get(agentId); + if (!supersede && (current?.status === "running" || current?.status === "complete")) { + return; + } + current?.cancelRetry?.(); + const generation = (catchUpGenerations.get(agentId) ?? 0) + 1; + catchUpGenerations.set(agentId, generation); + catchUps.set(agentId, { generation, status: "running" }); + pendingGaps.delete(agentId); + const nextRequest = request ?? planResumeTimelineSync({ cursor: ports.readCursor(agentId) }); + void fetchUntilCurrent(agentId, generation, nextRequest); + }; + + const startAcknowledgedCatchUps = () => { + for (const agentId of acknowledged) { + const gap = pendingGaps.get(agentId); + startCatchUp(agentId, { request: gap, supersede: Boolean(gap) }); + } + }; + + const reconcileLatestMembership = async (): Promise => { + 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 = []; + }, + }; +} diff --git a/packages/app/src/utils/agent-directory-reconciliation.test.ts b/packages/app/src/utils/agent-directory-reconciliation.test.ts new file mode 100644 index 000000000..ca9e9ba03 --- /dev/null +++ b/packages/app/src/utils/agent-directory-reconciliation.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import type { Agent } from "@/stores/session-store"; +import { reconcileAgentDirectory } from "./agent-directory-reconciliation"; + +function snapshot(id: string, status: AgentSnapshotPayload["status"]): AgentSnapshotPayload { + return { + id, + provider: "codex", + cwd: "/repo", + model: null, + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:00.000Z", + lastUserMessageAt: null, + status, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }; +} + +function entry(id: string, status: AgentSnapshotPayload["status"]): FetchAgentsEntry { + return { + agent: snapshot(id, status), + project: { + projectKey: "/repo", + projectName: "repo", + checkout: { + cwd: "/repo", + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }; +} + +function replica(id: string, status: Agent["status"]): Agent { + return { + ...snapshot(id, status), + serverId: "server", + createdAt: new Date("2026-07-12T10:00:00.000Z"), + updatedAt: new Date("2026-07-12T10:00:00.000Z"), + lastActivityAt: new Date("2026-07-12T10:00:00.000Z"), + lastUserMessageAt: null, + attentionTimestamp: null, + archivedAt: null, + parentAgentId: null, + }; +} + +describe("agent directory reconciliation", () => { + it("reports snapshot-only and buffered running transitions exactly once", () => { + const result = reconcileAgentDirectory({ + previous: new Map([ + ["snapshot", replica("snapshot", "running")], + ["buffered", replica("buffered", "running")], + ]), + snapshot: [entry("snapshot", "idle"), entry("buffered", "running")], + deltas: [ + { + kind: "upsert", + agent: snapshot("buffered", "idle"), + project: entry("buffered", "idle").project, + }, + { + kind: "upsert", + agent: snapshot("buffered", "idle"), + project: entry("buffered", "idle").project, + }, + ], + }); + + expect(result.stoppedRunningAgentIds).toEqual(["snapshot", "buffered"]); + expect(result.entries.map(({ agent }) => [agent.id, agent.status])).toEqual([ + ["snapshot", "idle"], + ["buffered", "idle"], + ]); + }); + + it("preserves ordered upserts and removals received after page one", () => { + const result = reconcileAgentDirectory({ + previous: new Map(), + snapshot: [entry("updated", "idle"), entry("removed", "idle")], + deltas: [ + { + kind: "upsert", + agent: { ...snapshot("updated", "idle"), title: "live" }, + project: entry("updated", "idle").project, + }, + { kind: "remove", agentId: "removed" }, + ], + }); + + expect(result.entries.map(({ agent }) => [agent.id, agent.title])).toEqual([ + ["updated", "live"], + ]); + }); + + it("keeps newer page metadata when a stale buffered upsert arrives", () => { + const result = reconcileAgentDirectory({ + previous: new Map([["agent", replica("agent", "running")]]), + snapshot: [ + { + ...entry("agent", "running"), + agent: { + ...snapshot("agent", "running"), + title: "newer page", + updatedAt: "2026-07-12T12:00:00.000Z", + }, + }, + ], + deltas: [ + { + kind: "upsert", + agent: { + ...snapshot("agent", "idle"), + title: "stale live", + updatedAt: "2026-07-12T11:00:00.000Z", + }, + project: entry("agent", "idle").project, + }, + ], + }); + + expect({ + title: result.entries[0]?.agent.title, + status: result.entries[0]?.agent.status, + stopped: result.stoppedRunningAgentIds, + }).toEqual({ title: "newer page", status: "running", stopped: [] }); + }); + + it("accepts usage from a stale buffered upsert without regressing metadata", () => { + const result = reconcileAgentDirectory({ + previous: new Map(), + snapshot: [ + { + ...entry("agent", "idle"), + agent: { + ...snapshot("agent", "idle"), + title: "newer page", + updatedAt: "2026-07-12T12:00:00.000Z", + lastUsage: { inputTokens: 10, outputTokens: 5 }, + }, + }, + ], + deltas: [ + { + kind: "upsert", + agent: { + ...snapshot("agent", "running"), + title: "stale live", + updatedAt: "2026-07-12T11:00:00.000Z", + lastUsage: { inputTokens: 20, outputTokens: 8 }, + }, + project: entry("agent", "idle").project, + }, + ], + }); + + expect({ + title: result.entries[0]?.agent.title, + status: result.entries[0]?.agent.status, + usage: result.entries[0]?.agent.lastUsage, + }).toEqual({ + title: "newer page", + status: "idle", + usage: { inputTokens: 20, outputTokens: 8 }, + }); + }); +}); diff --git a/packages/app/src/utils/agent-directory-reconciliation.ts b/packages/app/src/utils/agent-directory-reconciliation.ts new file mode 100644 index 000000000..14587cca9 --- /dev/null +++ b/packages/app/src/utils/agent-directory-reconciliation.ts @@ -0,0 +1,59 @@ +import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import type { Agent } from "@/stores/session-store"; +import type { AgentDirectoryDelta } from "./agent-directory-sync"; +import { acceptAgentDirectoryUpdate } from "./agent-directory-update-policy"; + +export function reconcileAgentDirectory(input: { + previous: ReadonlyMap; + 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(); + + for (const entry of input.snapshot) { + if (statuses.get(entry.agent.id) === "running" && entry.agent.status !== "running") { + stoppedRunningAgentIds.add(entry.agent.id); + } + statuses.set(entry.agent.id, entry.agent.status); + } + + for (const delta of input.deltas) { + if (delta.kind === "remove") { + entries.delete(delta.agentId); + statuses.delete(delta.agentId); + stoppedRunningAgentIds.delete(delta.agentId); + continue; + } + const previousEntry = entries.get(delta.agent.id); + const acceptedAgent = acceptAgentDirectoryUpdate(previousEntry?.agent, delta.agent); + if (statuses.get(delta.agent.id) === "running" && acceptedAgent.status !== "running") { + stoppedRunningAgentIds.add(delta.agent.id); + } + statuses.set(delta.agent.id, acceptedAgent.status); + const previousProject = previousEntry?.project; + entries.set(delta.agent.id, { + agent: acceptedAgent, + project: delta.project ?? + previousProject ?? { + projectKey: delta.agent.cwd, + projectName: /[^/]+$/.exec(delta.agent.cwd)?.[0] ?? delta.agent.cwd, + checkout: { + cwd: delta.agent.cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }); + } + + return { + entries: Array.from(entries.values()), + stoppedRunningAgentIds: Array.from(stoppedRunningAgentIds), + }; +} diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index d1c0d1df1..a136a1af0 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -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> & { @@ -56,6 +60,10 @@ function createEntry(agent: AgentSnapshotPayload): FetchAgentsEntry { }; } +function permission(id: string): AgentPermissionRequest { + return { id, provider: "codex", name: id, kind: "tool", title: id }; +} + describe("replaceFetchedAgentDirectory", () => { it("re-derives parentAgentId every time an agent snapshot is ingested", () => { const serverId = "server-1"; @@ -92,4 +100,139 @@ describe("replaceFetchedAgentDirectory", () => { store.clearSession(serverId); }); + + it("removes every replica-owned artifact for a removed agent", () => { + const serverId = "server-removal"; + const agentId = "removed-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const agent = { + ...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId), + projectPlacement: null, + }; + store.setAgents(serverId, new Map([[agentId, agent]])); + store.setAgentDetails(serverId, new Map([[agentId, agent]])); + store.setQueuedMessages( + serverId, + new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), + ); + store.setAgentTimelineCursor( + serverId, + new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]), + ); + store.setPendingPermissions( + serverId, + new Map([["permission", { key: "permission", agentId, request: null as never }]]), + ); + setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); + + applyAgentDirectoryDelta({ serverId, delta: { kind: "remove", agentId } }); + + const session = useSessionStore.getState().sessions[serverId]; + expect({ + agents: session?.agents.has(agentId), + details: session?.agentDetails.has(agentId), + queued: session?.queuedMessages.has(agentId), + cursor: session?.agentTimelineCursor.has(agentId), + permissions: session?.pendingPermissions.size, + archivePending: isAgentArchiving({ queryClient, serverId, agentId }), + }).toEqual({ + agents: false, + details: false, + queued: false, + cursor: false, + permissions: 0, + archivePending: false, + }); + + store.clearSession(serverId); + }); + + it("keeps newer metadata while accepting usage-only updates and legacy workspace ownership", () => { + const serverId = "server-usage"; + const agentId = "usage-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + store.setWorkspaces( + serverId, + new Map([ + [ + "legacy-workspace", + { + id: "legacy-workspace", + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "repo", + status: "done", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }, + ], + ]), + ); + const current = createAgentPayload({ + id: agentId, + title: "current", + status: "running", + updatedAt: "2026-07-12T11:00:00.000Z", + lastUsage: { inputTokens: 10, outputTokens: 5 }, + pendingPermissions: [permission("current-permission")], + }); + applyAgentDirectoryDelta({ + serverId, + delta: { kind: "upsert", agent: current, project: createEntry(current).project }, + }); + store.flushAgentLastActivity(); + setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); + + const staleResult = applyAgentDirectoryDelta({ + serverId, + delta: { + kind: "upsert", + agent: { + ...current, + title: "stale", + status: "idle", + updatedAt: "2026-07-12T10:00:00.000Z", + lastUsage: { inputTokens: 20, outputTokens: 8 }, + pendingPermissions: [permission("stale-permission")], + archivedAt: "2026-07-12T10:00:00.000Z", + }, + project: createEntry(current).project, + }, + }); + store.flushAgentLastActivity(); + + const state = useSessionStore.getState(); + const agent = state.sessions[serverId]?.agents.get(agentId); + expect({ + title: agent?.title, + status: agent?.status, + usage: agent?.lastUsage, + workspaceId: agent?.workspaceId, + stoppedRunning: staleResult.stoppedRunning, + permissions: Array.from(state.sessions[serverId]?.pendingPermissions.values() ?? []).map( + ({ request }) => request.id, + ), + archivePending: isAgentArchiving({ queryClient, serverId, agentId }), + activity: state.agentLastActivity.get(agentId)?.toISOString(), + }).toEqual({ + title: "current", + status: "running", + usage: { inputTokens: 20, outputTokens: 8 }, + workspaceId: "legacy-workspace", + stoppedRunning: false, + permissions: ["current-permission"], + archivePending: true, + activity: "2026-07-12T11:00:00.000Z", + }); + + store.clearSession(serverId); + }); }); diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index 684aaf82b..30039faa6 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -2,8 +2,113 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import { type Agent, useSessionStore } from "@/stores/session-store"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; +import { queryClient } from "@/data/query-client"; +import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy"; type AgentDirectoryFetchEntry = FetchAgentsEntry; +export type AgentDirectoryDelta = Extract< + SessionOutboundMessage, + { type: "agent_update" } +>["payload"]; + +export function applyAgentDirectoryDelta(input: { serverId: string; delta: AgentDirectoryDelta }): { + agentId: string; + stoppedRunning: boolean; +} { + if (input.delta.kind === "remove") { + removeAgentDirectoryReplica(input.serverId, input.delta.agentId); + return { agentId: input.delta.agentId, stoppedRunning: false }; + } + return upsertAgentDirectoryReplica(input.serverId, input.delta); +} + +type AgentUpsertDelta = Extract; + +function upsertAgentDirectoryReplica( + serverId: string, + delta: AgentUpsertDelta, +): { agentId: string; stoppedRunning: boolean } { + const normalized = normalizeAgentSnapshot(delta.agent, serverId); + const session = useSessionStore.getState().sessions[serverId]; + const previousAgent = + session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id); + const legacyWorkspaceId = + previousAgent?.workspaceId ?? + Array.from(session?.workspaces.values() ?? []).find( + (workspace) => + session?.serverInfo?.features?.workspaceMultiplicity !== true && + workspace.workspaceDirectory === normalized.cwd, + )?.id; + const agent: Agent = { + ...normalized, + workspaceId: normalized.workspaceId ?? legacyWorkspaceId, + projectPlacement: + resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ?? + previousAgent?.projectPlacement, + }; + const acceptedAgent = upsertAgentReplica(serverId, agent); + if (acceptedAgent.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId, agentId: acceptedAgent.id }); + } + replaceAgentPendingPermissions(serverId, acceptedAgent); + useSessionStore.getState().setAgentLastActivity(acceptedAgent.id, acceptedAgent.lastActivityAt); + return { + agentId: acceptedAgent.id, + stoppedRunning: previousAgent?.status === "running" && acceptedAgent.status !== "running", + }; +} + +function upsertAgentReplica(serverId: string, agent: Agent): Agent { + let acceptedAgent = agent; + useSessionStore.getState().setAgents(serverId, (current) => { + const currentAgent = current.get(agent.id); + acceptedAgent = acceptAgentDirectoryUpdate(currentAgent, agent); + if (acceptedAgent === currentAgent) return current; + const next = new Map(current); + next.set(agent.id, acceptedAgent); + return next; + }); + return acceptedAgent; +} + +function replaceAgentPendingPermissions(serverId: string, agent: Agent): void { + const pendingPermissions = new Map( + useSessionStore.getState().sessions[serverId]?.pendingPermissions, + ); + for (const [key, pending] of pendingPermissions) { + if (pending.agentId === agent.id) pendingPermissions.delete(key); + } + for (const request of agent.pendingPermissions) { + const key = derivePendingPermissionKey(agent.id, request); + pendingPermissions.set(key, { key, agentId: agent.id, request }); + } + useSessionStore.getState().setPendingPermissions(serverId, pendingPermissions); +} + +function removeAgentDirectoryReplica(serverId: string, agentId: string): void { + const store = useSessionStore.getState(); + clearArchiveAgentPending({ queryClient, serverId, agentId }); + const removeKey = (current: Map): Map => { + if (!current.has(agentId)) return current; + const next = new Map(current); + next.delete(agentId); + return next; + }; + store.setAgents(serverId, removeKey); + store.setAgentDetails(serverId, removeKey); + store.setQueuedMessages(serverId, removeKey); + store.setAgentTimelineCursor(serverId, removeKey); + store.setPendingPermissions(serverId, (current) => { + const next = new Map(current); + for (const [key, pending] of next) { + if (pending.agentId === agentId) next.delete(key); + } + return next.size === current.size ? current : next; + }); + store.setAgentAuthoritativeHistoryApplied(serverId, agentId, false); +} interface PendingPermissionEntry { key: string; @@ -49,6 +154,12 @@ export function replaceFetchedAgentDirectory(input: { const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input); const store = useSessionStore.getState(); + for (const agent of fetchedAgents.values()) { + if (agent.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: input.serverId, agentId: agent.id }); + } + } + store.setAgents(input.serverId, fetchedAgents); store.setAgentDetails(input.serverId, (prev) => { let next: Map | null = null; diff --git a/packages/app/src/utils/agent-directory-update-policy.ts b/packages/app/src/utils/agent-directory-update-policy.ts new file mode 100644 index 000000000..a802ae3b9 --- /dev/null +++ b/packages/app/src/utils/agent-directory-update-policy.ts @@ -0,0 +1,20 @@ +import equal from "fast-deep-equal"; +import type { AgentUsage } from "@getpaseo/protocol/agent-types"; + +interface AgentUpdateValue { + updatedAt: Date | string; + lastUsage?: AgentUsage; +} + +function timestamp(value: Date | string): number { + return value instanceof Date ? value.getTime() : Date.parse(value); +} + +export function acceptAgentDirectoryUpdate( + current: T | undefined, + incoming: T, +): T { + if (!current || timestamp(incoming.updatedAt) >= timestamp(current.updatedAt)) return incoming; + if (equal(incoming.lastUsage, current.lastUsage)) return current; + return { ...current, lastUsage: incoming.lastUsage }; +} diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts index 23de643b3..5979edfa6 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.ts @@ -121,7 +121,7 @@ export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty( return true; } -async function readLegacyDaemonWorkspaceDirectory(input: { +export async function readLegacyDaemonWorkspaceDirectory(input: { client: Pick; 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 { diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 3b3183862..383cdde5a 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -76,7 +76,7 @@ function createMockTransport() { return { transport, sent, - triggerOpen: (options?: { preserveSent?: boolean }) => { + triggerOpen: (options?: { preserveSent?: boolean; features?: Record }) => { onOpen(); if (!options?.preserveSent) { // Ignore HELLO handshake payloads in assertions. @@ -92,6 +92,7 @@ function createMockTransport() { serverId: `srv_test_${serverInfoOrdinal++}`, hostname: null, version: null, + ...(options?.features ? { features: options.features } : {}), }, }, }), @@ -219,6 +220,95 @@ test("advertises consumer-provided browser automation capabilities", async () => }); }); +test("sets the complete viewed timeline subscription only when the daemon supports it", async () => { + const supportedTransport = createMockTransport(); + const supportedClient = new DaemonClient({ + url: "ws://test", + clientId: "timeline_supported", + transportFactory: () => supportedTransport.transport, + reconnect: { enabled: false }, + }); + const legacyTransport = createMockTransport(); + const legacyClient = new DaemonClient({ + url: "ws://test", + clientId: "timeline_legacy", + transportFactory: () => legacyTransport.transport, + reconnect: { enabled: false }, + }); + clients.push(supportedClient, legacyClient); + + const supportedConnect = supportedClient.connect(); + supportedTransport.triggerOpen({ features: { selectiveAgentTimeline: true } }); + await supportedConnect; + const legacyConnect = legacyClient.connect(); + legacyTransport.triggerOpen(); + await legacyConnect; + + expect(supportedClient.getLastServerInfoMessage()?.features).toEqual({ + selectiveAgentTimeline: true, + }); + + const setPromise = supportedClient.setAgentTimelineSubscription(["agent-b", "agent-a"]); + await Promise.resolve(); + const request = parseSentFrame(supportedTransport.sent[0]); + supportedTransport.triggerMessage( + wrapSessionMessage({ + type: "agent.timeline.set_subscription.response", + payload: { + requestId: request.requestId, + agentIds: ["agent-a", "agent-b"], + }, + }), + ); + await setPromise; + await legacyClient.setAgentTimelineSubscription(["agent-a"]); + + expect({ request, legacyFrames: legacyTransport.sent }).toEqual({ + request: { + type: "agent.timeline.set_subscription.request", + requestId: expect.any(String), + agentIds: ["agent-a", "agent-b"], + }, + legacyFrames: [], + }); +}); + +test("normalizes legacy and dedicated agent attention notifications", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "attention_normalization", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connect = client.connect(); + mock.triggerOpen(); + await connect; + const notifications: unknown[] = []; + client.onAgentAttentionRequired((notification) => notifications.push(notification)); + const payload = { + agentId: "agent-a", + reason: "finished", + timestamp: "2026-07-12T00:00:00.000Z", + shouldNotify: true, + } as const; + + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_stream", + payload: { + agentId: payload.agentId, + timestamp: payload.timestamp, + event: { type: "attention_required", provider: "codex", ...payload }, + }, + }), + ); + mock.triggerMessage(wrapSessionMessage({ type: "agent_attention_required", payload })); + + expect(notifications).toEqual([payload, payload]); +}); + const noopLogger: Logger = { debug: () => {}, info: () => {}, @@ -557,6 +647,7 @@ test("advertises client capabilities in hello", async () => { custom_mode_icons: true, provider_subagents: true, reasoning_merge_enum: true, + selective_agent_timeline: true, terminal_reflowable_snapshot: true, browser_host: { supportedCommands: ["list_tabs"], diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 33a801b35..aa270ca76 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -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>; 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 { + // COMPAT(selectiveAgentTimeline): added in v0.1.106. Old daemons keep their + // legacy global stream and do not understand this RPC. Remove after + // 2027-01-12 once the supported daemon floor is >= v0.1.106. + if (!this.lastServerInfoMessage?.features?.selectiveAgentTimeline) { + return; + } + + const requestId = this.createRequestId(); + const normalizedAgentIds = [...new Set(agentIds)].sort(); + const message = SessionInboundMessageSchema.parse({ + type: "agent.timeline.set_subscription.request", + agentIds: normalizedAgentIds, + requestId, + }); + + await this.sendRequest({ + requestId, + message, + options: { skipQueue: true }, + select: (response) => { + if (response.type !== "agent.timeline.set_subscription.response") { + return null; + } + return response.payload.requestId === requestId ? response.payload : null; + }, + }); + } + async buildAgentForkContext( agentId: string, options: AgentForkContextOptions = {}, @@ -5045,6 +5108,7 @@ export class DaemonClient { protocolVersion: 1, capabilities: { [CLIENT_CAPS.customModeIcons]: true, + [CLIENT_CAPS.selectiveAgentTimeline]: true, [CLIENT_CAPS.reasoningMergeEnum]: true, [CLIENT_CAPS.terminalReflowableSnapshot]: true, [CLIENT_CAPS.providerSubagents]: true, diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index 63e01a5e4..dcf428ffd 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -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 diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index dec27d458..29dd7d550 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -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", + }, + }, + }); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index cf01fc15d..5d3320589 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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(), diff --git a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts new file mode 100644 index 000000000..52246e04e --- /dev/null +++ b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, expect, test } from "vitest"; +import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { DaemonClient } from "./test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js"; + +interface MessageWaiter { + predicate(message: SessionOutboundMessage): boolean; + resolve(message: SessionOutboundMessage): void; + reject(error: Error): void; + timeout: ReturnType; +} + +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 { + 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 { + 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 { + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + clientId: input.clientId, + capabilities: { [CLIENT_CAPS.selectiveAgentTimeline]: input.selective }, + reconnect: { enabled: false }, + }); + await client.connect(); + const connected = new ConnectedClient(client); + clients.push(connected); + return connected; +} + +test("real WebSocket sessions enforce selective delivery, retained resets, downgrade, and dedicated attention", async () => { + const legacy = await connect({ clientId: "legacy-client", selective: false }); + let capable = await connect({ clientId: "capable-client", selective: true }); + const agents = await Promise.all( + ["A", "B", "C"].map((title) => + legacy.client.createAgent({ + provider: "codex", + cwd: "/tmp", + title: `Selective ${title}`, + modeId: "full-access", + }), + ), + ); + const [agentA, agentB, agentC] = agents; + legacy.clear(); + capable.clear(); + + await daemon.daemon.agentManager.emitLiveTimelineItem(agentC.id, { + type: "assistant_message", + text: "before membership", + }); + await legacy.next(isAgentStream(agentC.id), "legacy global delivery before membership"); + await capable.barrier("before-membership"); + expect(capable.hasTimeline(agentC.id)).toBe(false); + + await capable.client.setAgentTimelineSubscription([agentA.id, agentB.id]); + legacy.clear(); + capable.clear(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { + type: "assistant_message", + text: "viewed A", + }); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, { + type: "assistant_message", + text: "viewed B", + }); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentC.id, { + type: "assistant_message", + text: "unviewed C", + }); + await Promise.all([ + capable.next(isAgentStream(agentA.id), "capable A delivery"), + capable.next(isAgentStream(agentB.id), "capable B delivery"), + legacy.next(isAgentStream(agentC.id), "legacy C delivery"), + ]); + await capable.barrier("unviewed-c"); + expect(capable.hasTimeline(agentC.id)).toBe(false); + + await capable.client.setAgentTimelineSubscription([agentB.id]); + legacy.clear(); + capable.clear(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { + type: "assistant_message", + text: "removed A", + }); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, { + type: "assistant_message", + text: "retained B", + }); + await Promise.all([ + legacy.next(isAgentStream(agentA.id), "legacy removed A delivery"), + capable.next(isAgentStream(agentB.id), "capable retained B delivery"), + ]); + await capable.barrier("removed-a"); + expect(capable.hasTimeline(agentA.id)).toBe(false); + + capable.close(); + await capable.client.close(); + clients.splice(clients.indexOf(capable), 1); + capable = await connect({ clientId: "capable-client", selective: true }); + legacy.clear(); + capable.clear(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, { + type: "assistant_message", + text: "after capable resume", + }); + await legacy.next(isAgentStream(agentB.id), "legacy delivery after capable resume"); + await capable.barrier("resumed-membership-reset"); + expect(capable.hasTimeline(agentB.id)).toBe(false); + + capable.client.sendHeartbeat({ + deviceType: "mobile", + focusedAgentId: null, + lastActivityAt: new Date().toISOString(), + appVisible: true, + }); + legacy.clear(); + capable.clear(); + const attention = capable.next( + isDedicatedAttention(agentC.id), + "capable dedicated attention notification", + ); + const legacyAttention = legacy.next( + isLegacyAttention(agentC.id), + "legacy attention stream notification", + ); + await legacy.client.sendMessage(agentC.id, "finish attention boundary test"); + const [attentionMessage, legacyAttentionMessage] = await Promise.all([ + attention, + legacyAttention, + ]); + await capable.barrier("attention-delivery"); + expect({ + capable: dedicatedAttentionResult(attentionMessage, capable.hasTimeline(agentC.id)), + legacy: legacyAttentionResult(legacyAttentionMessage), + }).toEqual({ + capable: { + type: "agent_attention_required", + shouldNotify: true, + timelineLeaked: false, + }, + legacy: { + type: "agent_stream", + eventType: "attention_required", + agentId: agentC.id, + }, + }); + + capable.close(); + await capable.client.close(); + clients.splice(clients.indexOf(capable), 1); + const downgraded = await connect({ clientId: "capable-client", selective: false }); + downgraded.clear(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { + type: "assistant_message", + text: "after downgrade", + }); + const downgradedDelivery = await downgraded.next( + isAgentStream(agentA.id), + "legacy global delivery after capability downgrade", + ); + + expect(downgradedDelivery.type).toBe("agent_stream"); +}, 30_000); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index c0fcff4bb..78e4220c2 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -4554,6 +4554,28 @@ describe("chat/schedule/loop dispatch routing (behavior preservation)", () => { }); }); +test("replaces a capable session's complete viewed timeline set", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages }); + session.updateClientCapabilities({ selective_agent_timeline: true }); + + await session.handleMessage({ + type: "agent.timeline.set_subscription.request", + agentIds: ["agent-b", "agent-a", "agent-a"], + requestId: "timeline-subscription-1", + }); + + expect(messages).toEqual([ + { + type: "agent.timeline.set_subscription.response", + payload: { + agentIds: ["agent-a", "agent-b"], + requestId: "timeline-subscription-1", + }, + }, + ]); +}); + describe("agent config setters", () => { test("set_agent_mode_request: success emits accepted response carrying the notice", async () => { const messages: SessionOutboundMessage[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2416c77e3..efffedc79 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -583,6 +583,7 @@ export class Session { private readonly daemonConfigStore: DaemonConfigStore; private readonly pushTokenStore: PushTokenStore; private unsubscribeAgentEvents: (() => void) | null = null; + private viewedTimelineAgentIds = new Set(); private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; private readonly agentUpdates: AgentUpdatesService; private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null; @@ -965,6 +966,9 @@ export class Session { updateClientCapabilities(capabilities: Record | null): void { this.clientCapabilities = parseClientCapabilities(capabilities); + if (this.supports(CLIENT_CAPS.selectiveAgentTimeline)) { + this.viewedTimelineAgentIds.clear(); + } } supports(capability: ClientCapability): boolean { @@ -979,6 +983,16 @@ export class Session { await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true }); } + private async emitCreatedWorkspaceUpdate(workspace: WorkspaceDescriptorPayload): Promise { + 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 { await this.archiveWorkspaceRecord(workspaceId); } @@ -1271,10 +1285,31 @@ export class Session { "agent.session.forward_stream", ); - this.emit({ - type: "agent_stream", - payload: this.buildAgentStreamPayload(event, serializedEvent), - }); + if ( + this.supports(CLIENT_CAPS.selectiveAgentTimeline) && + serializedEvent.type === "attention_required" + ) { + this.emit({ + type: "agent_attention_required", + payload: { + agentId: event.agentId, + reason: serializedEvent.reason, + timestamp: serializedEvent.timestamp, + shouldNotify: serializedEvent.shouldNotify, + ...(serializedEvent.notification + ? { notification: serializedEvent.notification } + : {}), + }, + }); + } else if ( + !this.supports(CLIENT_CAPS.selectiveAgentTimeline) || + this.viewedTimelineAgentIds.has(event.agentId) + ) { + this.emit({ + type: "agent_stream", + payload: this.buildAgentStreamPayload(event, serializedEvent), + }); + } if (event.event.type === "permission_requested") { this.emit({ @@ -1524,6 +1559,17 @@ export class Session { return this.handleProviderSubagentListRequest(msg); case "agent.provider_subagents.timeline.get.request": return this.handleProviderSubagentTimelineRequest(msg); + case "agent.timeline.set_subscription.request": { + const agentIds = [...new Set(msg.agentIds)].sort(); + if (this.supports(CLIENT_CAPS.selectiveAgentTimeline)) { + this.viewedTimelineAgentIds = new Set(agentIds); + } + this.emit({ + type: "agent.timeline.set_subscription.response", + payload: { agentIds, requestId: msg.requestId }, + }); + return undefined; + } case "agent.fork_context.request": return this.handleAgentForkContextRequest(msg); default: @@ -4259,6 +4305,9 @@ export class Session { this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace); if (!nextWorkspace) { + if (workspace && !subscription.lastEmittedByWorkspaceId.has(workspaceId)) { + continue; + } subscription.lastEmittedByWorkspaceId.delete(workspaceId); this.bufferOrEmitWorkspaceUpdate( subscription, @@ -4661,7 +4710,7 @@ export class Session { error: null, }, }); - await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); + await this.emitCreatedWorkspaceUpdate(descriptor); void this.workspaceGitService .getSnapshot(workspace.cwd, { force: true, includeForge: true, reason: "open_project" }) .catch((error) => { @@ -4746,10 +4795,7 @@ export class Session { error: null, }, }); - this.emit({ - type: "workspace_update", - payload: { kind: "upsert", workspace: descriptor }, - }); + await this.emitCreatedWorkspaceUpdate(descriptor); } private async resolveWorktreeSourceCwd(input: { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 1b23c3bbf..04ccbd14b 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -7827,4 +7827,75 @@ test("workspace.create.response persists the first prompt as the initial title", expect(workspaceId).toBeDefined(); const persisted = await session.workspaceRegistry.get(workspaceId as string); expect(persisted?.title).toBe("Add retries to the payments flow"); + expect(filterByType(emitted, "workspace_update")).toHaveLength(1); +}); + +test("workspace create emits through a matching workspace subscription", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspaces = new Map>(); + 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>(); + 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([]); }); diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index e0eba9362..dbe062296 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -173,6 +173,7 @@ function createSessionWithActivity( ) { return { getClientActivity: vi.fn(() => activity), + supports: () => false, }; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index cbe2a13af..58a558246 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1163,12 +1163,15 @@ export class VoiceAssistantWebSocketServer { existing.session.updateAppVersion(newAppVersion); } const newClientCapabilities = message.capabilities ?? null; + // COMPAT(selectiveAgentTimeline): added in v0.1.106. Every capable resumed + // hello resets membership before server_info so stale retained-session + // state cannot leak. Remove after 2027-01-12. + existing.session.updateClientCapabilities(newClientCapabilities); if ( JSON.stringify(existing.clientCapabilities ?? null) !== JSON.stringify(newClientCapabilities ?? null) ) { existing.clientCapabilities = newClientCapabilities; - existing.session.updateClientCapabilities(newClientCapabilities); this.syncBrowserToolsClientRegistration(existing); } existing.sockets.add(ws); @@ -1281,6 +1284,8 @@ export class VoiceAssistantWebSocketServer { importSessionWorkspaceTarget: true, // COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106. forgeProviders: true, + // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. + selectiveAgentTimeline: true, }, }; } @@ -2009,21 +2014,36 @@ export class VoiceAssistantWebSocketServer { for (const [clientIndex, { ws }] of clientEntries.entries()) { const shouldNotify = clientIndex === plan.inAppRecipientIndex; const timestamp = new Date().toISOString(); - const message = wrapSessionMessage({ - type: "agent_stream", - payload: { - agentId: params.agentId, - event: { - type: "attention_required", - provider: params.provider, - reason: params.reason, - timestamp, - shouldNotify, - notification, - }, - timestamp, - }, - }); + const connection = this.sessions.get(ws); + const attentionPayload = { + agentId: params.agentId, + reason: params.reason, + timestamp, + shouldNotify, + notification, + }; + const message = wrapSessionMessage( + connection?.session.supports(CLIENT_CAPS.selectiveAgentTimeline) + ? { + type: "agent_attention_required", + payload: attentionPayload, + } + : { + type: "agent_stream", + payload: { + agentId: params.agentId, + event: { + type: "attention_required", + provider: params.provider, + reason: params.reason, + timestamp, + shouldNotify, + notification, + }, + timestamp, + }, + }, + ); this.sendToClient(ws, message); }