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..7e4c7d539 --- /dev/null +++ b/packages/app/e2e/viewed-agent-timelines.spec.ts @@ -0,0 +1,148 @@ +import { expect, type Page } from "@playwright/test"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { test } from "./fixtures"; +import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { + expectReconnectingToastGone, + expectReconnectingToastVisible, +} from "./helpers/workspace-ui"; + +interface ViewedTimelineScenario { + client: SeedDaemonClient; + workspaceId: string; + firstAgentId: string; + secondAgentId: string; + cleanup(): Promise; +} + +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 enableMoveTabShortcut(page: Page) { + await page.addInitScript(() => { + Object.defineProperty(navigator, "platform", { get: () => "MacIntel" }); + }); +} + +async function moveActiveTabRight(page: Page) { + await page.keyboard.press("Meta+Alt+Shift+ArrowRight"); +} + +async function commitMessage(scenario: ViewedTimelineScenario, agentId: string, prompt: string) { + await scenario.client.sendAgentMessage(agentId, prompt); + const finish = await scenario.client.waitForFinish(agentId, 30_000); + expect(finish.status).toBe("idle"); +} + +test.describe("Viewed agent timelines", () => { + test("a hidden retained chat catches up when shown", async ({ page }) => { + const scenario = await seedViewedTimelineScenario(); + try { + await openAgent(page, scenario, scenario.firstAgentId); + await selectAgent(page, "Second viewed chat"); + await commitMessage( + scenario, + scenario.firstAgentId, + "Committed while the first chat is hidden.", + ); + await expect( + page.getByText("Committed while the first chat is hidden.", { exact: true }), + ).toHaveCount(0); + await selectAgent(page, "First viewed chat"); + await expect( + page.getByText("Committed while the first chat is hidden.", { exact: true }), + ).toBeVisible(); + } finally { + await scenario.cleanup(); + } + }); + + test("two visible split chats both stay current", async ({ page }) => { + const scenario = await seedViewedTimelineScenario(); + try { + await enableMoveTabShortcut(page); + await openAgent(page, scenario, scenario.firstAgentId); + await page.getByRole("button", { name: "Split pane right" }).click(); + await selectAgent(page, "Second viewed chat"); + await moveActiveTabRight(page); + await expect( + page.getByRole("button", { name: "First viewed chat", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Second viewed chat", exact: true }), + ).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(2); + await commitMessage(scenario, scenario.firstAgentId, "First visible pane update."); + await expect(page.getByText("First visible pane update.", { exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Second viewed chat", exact: true }), + ).toBeVisible(); + } finally { + await scenario.cleanup(); + } + }); + + test("a visible chat catches up after reconnecting", async ({ page }) => { + const gate = await installDaemonWebSocketGate(page); + const scenario = await seedViewedTimelineScenario(); + try { + await openAgent(page, scenario, scenario.firstAgentId); + await expect(page.getByRole("button", { name: "First viewed chat" })).toHaveAttribute( + "aria-selected", + "true", + ); + await gate.drop(); + await expectReconnectingToastVisible(page); + await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects."); + await expect( + page.getByText("Committed while the chat reconnects.", { exact: true }), + ).toHaveCount(0); + gate.restore(); + await expectReconnectingToastGone(page); + const recoveredMessage = page.getByText("Committed while the chat reconnects.", { + exact: true, + }); + await expect(recoveredMessage).toHaveCount(1); + await expect(recoveredMessage).toBeVisible(); + } finally { + gate.restore(); + await scenario.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/workspace-model-restart.spec.ts b/packages/app/e2e/workspace-model-restart.spec.ts index 295234094..3f459bf46 100644 --- a/packages/app/e2e/workspace-model-restart.spec.ts +++ b/packages/app/e2e/workspace-model-restart.spec.ts @@ -20,7 +20,7 @@ import { import { waitForSidebarHydration } from "./helpers/workspace-ui"; import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; -const LEGACY_AGENT_ID = "legacy-cwd-only-agent"; +const LEGACY_AGENT_ID = "10000000-0000-4000-8000-000000000001"; const SERVER_ID = `srv_restart_${randomUUID().replace(/-/g, "").slice(0, 16)}`; interface RestartDaemonClient { 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/components/rewind/use-rewind-agent-mutation.ts b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts index e684c4bf5..037f7c733 100644 --- a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts +++ b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts @@ -8,6 +8,7 @@ import { useRewindComposerRestore } from "./composer-restore"; import { useSessionStore } from "@/stores/session-store"; import { shouldRestoreComposerForRewindMode } from "./rewind-mode"; import { clearOptimisticUserMessages } from "@/types/stream"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; interface UseRewindAgentMutationInput { serverId?: string; @@ -47,7 +48,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { .getState() .sessions[input.serverId]?.agentTimelineCursor.get(input.agentId) : undefined; - await input.client.fetchAgentTimeline(input.agentId, { + if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable")); + await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, { direction: "tail", projection: "projected", ...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}), diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index b03702a81..31720b34d 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -4,9 +4,12 @@ import { AppState } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useClientActivity } from "@/hooks/use-client-activity"; +import { useAppVisible } from "@/hooks/use-app-visible"; 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"; @@ -37,51 +35,27 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; import type { AudioPlaybackSource } from "@/voice/audio-engine-types"; -import { - useSessionStore, - type Agent, - type MessageEntry, - type SessionState, - type WorkspaceDescriptor, - type EmptyProjectDescriptor, - normalizeWorkspaceDescriptor, - normalizeEmptyProjectDescriptor, -} from "@/stores/session-store"; -import { useDraftStore } from "@/stores/draft-store"; +import { useSessionStore, type MessageEntry, type SessionState } from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { sendOsNotification } from "@/utils/os-notifications"; 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 { derivePendingPermissionKey } from "@/utils/agent-snapshots"; 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 { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"; -import { - applyLegacyDaemonWorkspaceOwnership, - backfillLegacyDaemonWorkspaceDirectoryIfEmpty, -} from "@/workspace/legacy-daemon-workspaces"; import { useProviderSubagentStore } from "@/subagents/provider-store"; +import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -96,25 +70,6 @@ export type { AgentFileExplorerState, } from "@/stores/session-store"; -const HISTORY_STALE_AFTER_MS = 60_000; -const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300; - -function hasAgentUsageChanged( - incomingUsage: Agent["lastUsage"] | undefined, - currentUsage: Agent["lastUsage"] | undefined, -): boolean { - const keys: Array> = [ - "inputTokens", - "outputTokens", - "cachedInputTokens", - "totalCostUsd", - "contextWindowMaxTokens", - "contextWindowUsedTokens", - ]; - - return keys.some((key) => incomingUsage?.[key] !== currentUsage?.[key]); -} - type AudioOutputPayload = Extract["payload"]; interface BufferedAudioChunk { @@ -124,56 +79,6 @@ interface BufferedAudioChunk { id: string; } -interface WorkspaceHydrationSnapshot { - workspaces: Map; - emptyProjects: Map; -} - -async function fetchWorkspaceHydrationSnapshot(input: { - client: DaemonClient; - serverId: string; - subscribe: boolean; - isCancelled?: () => boolean; -}): Promise { - const workspaces = new Map(); - const emptyProjects = new Map(); - let cursor: string | null = null; - let includeSubscribe = input.subscribe; - - while (true) { - const payload = await input.client.fetchWorkspaces({ - sort: [{ key: "activity_at", direction: "desc" }], - ...(includeSubscribe ? { subscribe: {} } : {}), - page: cursor ? { limit: 200, cursor } : { limit: 200 }, - }); - if (input.isCancelled?.()) { - return null; - } - - for (const entry of payload.entries) { - const workspace = normalizeWorkspaceDescriptor(entry); - if (shouldSuppressWorkspaceForLocalArchive({ serverId: input.serverId, workspace })) { - continue; - } - workspaces.set(workspace.id, workspace); - } - - // Project parents with no active workspaces only ride on the first page. - for (const project of payload.emptyProjects ?? []) { - const descriptor = normalizeEmptyProjectDescriptor(project); - emptyProjects.set(descriptor.projectId, descriptor); - } - - if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { - break; - } - cursor = payload.pageInfo.nextCursor; - includeSubscribe = false; - } - - return { workspaces, emptyProjects }; -} - function decodeBase64Chunk(base64: string): Uint8Array { return Buffer.from(base64, "base64"); } @@ -240,54 +145,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 +263,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); } } } @@ -446,13 +300,16 @@ function finalizeTimelineApplication(input: { if (shouldMarkAuthoritativeHistoryApplied) { setAgentAuthoritativeHistoryApplied(serverId, agentId, true); useCreateFlowStore.getState().clearByAgent({ serverId, agentId }); + markAgentHistorySynchronized(serverId, agentId); + const session = useSessionStore.getState().sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + if (agent && agent.status !== "running") { + getHostRuntimeStore().drainQueuedAgentMessage(serverId, agentId); + } } if (result.initResolution === "resolve") { resolveInitDeferred(initKey); } - if (result.clearInitializing) { - markAgentHistorySynchronized(serverId, agentId); - } } function applyToolResultToMessages( @@ -540,24 +397,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setAgentAuthoritativeHistoryApplied = useSessionStore( (state) => state.setAgentAuthoritativeHistoryApplied, ); - const setHasHydratedAgents = useSessionStore((state) => state.setHasHydratedAgents); - const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); const setAgents = useSessionStore((state) => state.setAgents); const setWorkspaces = useSessionStore((state) => state.setWorkspaces); - const setEmptyProjects = useSessionStore((state) => state.setEmptyProjects); - const addEmptyProject = useSessionStore((state) => state.addEmptyProject); - const removeEmptyProject = useSessionStore((state) => state.removeEmptyProject); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); - const removeWorkspace = useSessionStore((state) => state.removeWorkspace); - const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity); 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); // Track focused agent for heartbeat @@ -567,28 +414,13 @@ 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 isAppVisible = useAppVisible(); useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { @@ -601,290 +433,31 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }, []); useEffect(() => { - previousAgentStatusRef.current = reconcilePreviousAgentStatuses( - previousAgentStatusRef.current, - sessionAgents, - ); - }, [sessionAgents]); + viewedTimelineSyncRef.current?.setActive(isAppVisible); + }, [isAppVisible]); - const hydrateWorkspaces = useCallback( - async (options?: { subscribe?: boolean; isCancelled?: () => boolean }) => { - if (!client || !isConnected) { - return; - } - - const snapshot = await fetchWorkspaceHydrationSnapshot({ - client, - serverId, - subscribe: options?.subscribe ?? false, - isCancelled: options?.isCancelled, - }); - if (!snapshot || options?.isCancelled?.()) { - return; - } - - const didBackfillLegacy = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({ - client, - serverId, - workspaces: snapshot.workspaces, - emptyProjects: snapshot.emptyProjects, - isCancelled: options?.isCancelled, - }); - if (didBackfillLegacy) { - return; - } - - setWorkspaces(serverId, snapshot.workspaces); - setEmptyProjects(serverId, snapshot.emptyProjects.values()); - setHasHydratedWorkspaces(serverId, true); - }, - [client, isConnected, serverId, setEmptyProjects, setHasHydratedWorkspaces, setWorkspaces], - ); - - const applyAuthoritativeAgentSnapshot = useCallback( - (agent: Agent) => { - setAgents(serverId, (prev) => { - const current = prev.get(agent.id); - if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) { - const hasUsageUpdate = hasAgentUsageChanged(agent.lastUsage, current.lastUsage); - if (hasUsageUpdate) { - const next = new Map(prev); - next.set(agent.id, { - ...current, - lastUsage: agent.lastUsage, - }); - return next; - } - return prev; - } - const next = new Map(prev); - next.set(agent.id, agent); - return next; - }); - - if (agent.archivedAt) { - clearArchiveAgentPending({ - queryClient, - serverId, - agentId: agent.id, - }); - } - - setAgentLastActivity(agent.id, agent.lastActivityAt); - - setPendingPermissions(serverId, (prev) => { - const existingKeysForAgent: string[] = []; - for (const [key, pending] of prev.entries()) { - if (pending.agentId === agent.id) { - existingKeysForAgent.push(key); - } - } - - const nextEntries = agent.pendingPermissions.map((request) => ({ - key: derivePendingPermissionKey(agent.id, request), - agentId: agent.id, - request, - })); - - let changed = existingKeysForAgent.length !== nextEntries.length; - if (!changed) { - const existingKeySet = new Set(existingKeysForAgent); - for (const entry of nextEntries) { - const existing = prev.get(entry.key); - if (!existingKeySet.has(entry.key) || !existing) { - changed = true; - break; - } - - const currentRequest = existing.request; - if ( - currentRequest.id !== entry.request.id || - currentRequest.kind !== entry.request.kind || - currentRequest.name !== entry.request.name || - currentRequest.title !== entry.request.title || - currentRequest.description !== entry.request.description - ) { - changed = true; - break; - } - } - } - - if (!changed) { - return prev; - } - - const next = new Map(prev); - for (const key of existingKeysForAgent) { - next.delete(key); - } - for (const entry of nextEntries) { - next.set(entry.key, entry); - } - 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, - ], - ); - - 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); + void revalidateSessionAfterResume({ + awayMs, + serverId, + bumpHistorySyncGeneration, + refreshDirectories: () => getHostRuntimeStore().refreshDirectories(serverId), + }).catch((error) => { + console.error("[SessionProvider] resume revalidation failed", { + serverId, + error: toErrorMessage(error), + }); + }); }, - [ - bumpHistorySyncGeneration, - client, - requestCanonicalCatchUp, - scheduleAuthoritativeRevalidation, - serverId, - ], + [bumpHistorySyncGeneration, serverId], ); // Client activity tracking (heartbeat, push token registration) @@ -944,11 +517,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,112 +599,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider useEffect(() => { if (!isConnected) { flushAgentLastActivity(); - clearPendingAgentUpdates(serverId); setInitializingAgents(serverId, new Map()); } }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); - useEffect(() => { - if (!client || !isConnected) { - return () => {}; - } - - let cancelled = false; - void (async () => { - try { - await hydrateWorkspaces({ - subscribe: true, - isCancelled: () => cancelled, - }); - } catch (error) { - console.error("[Session] Failed to hydrate workspaces:", error); - } - })(); - - return () => { - cancelled = true; - }; - }, [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 }); @@ -1172,19 +645,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return next; }); - if (payload.agent) { - const normalized = normalizeAgentSnapshot(payload.agent, serverId); - applyAuthoritativeAgentSnapshot( - applyLegacyDaemonWorkspaceOwnership({ - serverId, - agent: { - ...normalized, - projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null, - }, - }), - ); - } - // Call pure reducer const result = processTimelineResponse({ payload, @@ -1222,34 +682,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, @@ -1262,11 +697,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); }, [ - applyAuthoritativeAgentSnapshot, - applyAgentUpdatePayload, clearAgentStreamHead, markAgentHistorySynchronized, - requestCanonicalCatchUp, + recoverTimelineGap, serverId, setAgentAuthoritativeHistoryApplied, setAgentStreamHead, @@ -1278,54 +711,75 @@ 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), + hasAuthoritativeHistory: (agentId) => + useSessionStore + .getState() + .sessions[serverId]?.agentAuthoritativeHistoryApplied.get(agentId) === true, + fetchPage: async (agentId, request) => { + const session = useSessionStore.getState().sessions[serverId]; + const initKey = getInitKey(serverId, agentId); + if (session?.agentAuthoritativeHistoryApplied.get(agentId) !== true) { + if (!getInitDeferred(initKey)) { + const deferred = createInitDeferred(initKey, request.direction ?? "tail"); + void deferred.promise.catch(() => undefined); + } + refreshAgentInitializationTimeout({ + key: initKey, + agentId, + setAgentInitializing, + }); + setAgentInitializing(agentId, true); + } + try { + const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request); + if (getInitDeferred(initKey)) { + refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing }); + } + return page; + } 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 +796,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 +808,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); @@ -1377,30 +825,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); }); - 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); - } - return; - } - const workspace = normalizeWorkspaceDescriptor(message.payload.workspace); - if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) { - return; - } - mergeWorkspaces(serverId, [workspace]); - }); - const unsubScriptStatusUpdate = client.on("script_status_update", (message) => { if (message.type !== "script_status_update") return; setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload)); @@ -1662,102 +1086,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider voiceRuntime?.onServerSpeechStateChanged(serverId, message.payload.isSpeaking); }); - const unsubAgentDeleted = client.on("agent_deleted", (message) => { - if (message.type !== "agent_deleted") { - return; - } - const { agentId } = message.payload; - 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; - }); - - // Remove from agentLastActivity slice (top-level) - useSessionStore.setState((state) => { - if (!state.agentLastActivity.has(agentId)) { - return state; - } - const nextActivity = new Map(state.agentLastActivity); - nextActivity.delete(agentId); - return { - ...state, - agentLastActivity: nextActivity, - }; - }); - - setAgentStreamTail(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - clearAgentStreamHead(serverId, agentId); - setAgentTimelineCursor(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - - // Remove draft input - clearDraftInput({ - draftKey: buildDraftStoreKey({ serverId, agentId }), - }); - - setPendingPermissions(serverId, (prev) => { - let changed = false; - const next = new Map(prev); - for (const [key, pending] of prev.entries()) { - if (pending.agentId === agentId) { - next.delete(key); - changed = true; - } - } - return changed ? next : prev; - }); - - setInitializingAgents(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - }); - - const unsubAgentArchived = client.on("agent_archived", (message) => { - if (message.type !== "agent_archived") { - return; - } - const { agentId, archivedAt } = message.payload; - clearArchiveAgentPending({ queryClient, serverId, agentId }); - - setAgents(serverId, (prev) => { - const existing = prev.get(agentId); - if (!existing) { - return prev; - } - const next = new Map(prev); - next.set(agentId, { - ...existing, - archivedAt: new Date(archivedAt), - }); - return next; - }); - }); - const unsubTerminalAttention = client.on("terminal_attention_required", (message) => { if (message.type !== "terminal_attention_required") { return; @@ -1780,11 +1108,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); return () => { - unsubAgentUpdate(); unsubAgentStream(); unsubAgentTimeline(); unsubProviderSubagentUpdate(); - unsubWorkspaceUpdate(); + unsubAgentAttention(); unsubScriptStatusUpdate(); unsubCheckoutStatusUpdate(); unsubWorkspaceSetupProgress(); @@ -1797,8 +1124,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubChunk(); unsubTranscription(); unsubVoiceInputState(); - unsubAgentDeleted(); - unsubAgentArchived(); unsubTerminalAttention(); agentStreamReducerQueue.dispose({ flush: true }); }; @@ -1817,18 +1142,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setInitializingAgents, setAgents, setWorkspaces, - mergeWorkspaces, - removeWorkspace, - removeWorkspaceSetup, - addEmptyProject, - removeEmptyProject, - setAgentLastActivity, setPendingPermissions, - setHasHydratedAgents, - clearDraftInput, notifyAgentAttention, - requestCanonicalCatchUp, - applyAgentUpdatePayload, + recoverTimelineGap, applyWorkspaceSetupProgress, applyTimelineResponse, updateSessionServerInfo, @@ -1837,67 +1153,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/session-resume-revalidation.test.ts b/packages/app/src/contexts/session-resume-revalidation.test.ts new file mode 100644 index 000000000..ebc90b68a --- /dev/null +++ b/packages/app/src/contexts/session-resume-revalidation.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + revalidateSessionAfterResume, + SESSION_STALE_AFTER_MS, +} from "./session-resume-revalidation"; + +describe("session resume revalidation", () => { + it("refreshes both directories and timeline history after a stale resume", async () => { + const calls: string[] = []; + + const revalidated = await revalidateSessionAfterResume({ + awayMs: SESSION_STALE_AFTER_MS, + serverId: "server", + bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`), + refreshDirectories: async () => calls.push("directories"), + }); + + expect(revalidated).toBe(true); + expect(calls).toEqual(["history:server", "directories"]); + }); + + it("does nothing after a brief background interval", async () => { + const calls: string[] = []; + + const revalidated = await revalidateSessionAfterResume({ + awayMs: SESSION_STALE_AFTER_MS - 1, + serverId: "server", + bumpHistorySyncGeneration: () => calls.push("history"), + refreshDirectories: async () => calls.push("directories"), + }); + + expect(revalidated).toBe(false); + expect(calls).toEqual([]); + }); +}); diff --git a/packages/app/src/contexts/session-resume-revalidation.ts b/packages/app/src/contexts/session-resume-revalidation.ts new file mode 100644 index 000000000..8360316f5 --- /dev/null +++ b/packages/app/src/contexts/session-resume-revalidation.ts @@ -0,0 +1,16 @@ +export const SESSION_STALE_AFTER_MS = 60_000; + +export async function revalidateSessionAfterResume(input: { + awayMs: number; + serverId: string; + bumpHistorySyncGeneration: (serverId: string) => void; + refreshDirectories: () => Promise; +}): Promise { + if (input.awayMs < SESSION_STALE_AFTER_MS) { + return false; + } + + input.bumpHistorySyncGeneration(input.serverId); + await input.refreshDirectories(); + return true; +} 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..851f784c1 --- /dev/null +++ b/packages/app/src/contexts/workspace-directory-reconciliation.test.ts @@ -0,0 +1,63 @@ +import { expect, it } from "vitest"; +import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; +import { normalizeWorkspaceDescriptor } from "@/stores/session-store"; +import { + clearWorkspaceArchivePending, + markWorkspaceArchivePending, +} from "./session-workspace-upserts"; +import { reconcileWorkspaceDirectory } from "./workspace-directory-reconciliation"; + +const SERVER_ID = "workspace-directory-reconciliation"; + +function workspace(id: string, title: string): WorkspaceDescriptorPayload { + return { + id, + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/repo", + workspaceDirectory: `/repo/${id}`, + projectKind: "git", + workspaceKind: "worktree", + name: id, + title, + status: "done", + activityAt: null, + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }; +} + +it("keeps workspace upserts and removals received during later pages", () => { + const result = reconcileWorkspaceDirectory({ + serverId: SERVER_ID, + snapshot: new Map([ + ["updated", normalizeWorkspaceDescriptor(workspace("updated", "snapshot"))], + ["removed", normalizeWorkspaceDescriptor(workspace("removed", "snapshot"))], + ]), + deltas: [ + { kind: "upsert", workspace: workspace("updated", "live") }, + { kind: "remove", id: "removed" }, + ], + }); + + expect(Array.from(result.values()).map(({ id, title }) => [id, title])).toEqual([ + ["updated", "live"], + ]); +}); + +it("does not restore a locally archiving workspace from a buffered upsert", () => { + markWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "archiving" }); + try { + const result = reconcileWorkspaceDirectory({ + serverId: SERVER_ID, + snapshot: new Map(), + deltas: [{ kind: "upsert", workspace: workspace("archiving", "live") }], + }); + + expect(result.has("archiving")).toBe(false); + } finally { + clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "archiving" }); + } +}); 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..4be9cdd9f --- /dev/null +++ b/packages/app/src/contexts/workspace-directory-reconciliation.ts @@ -0,0 +1,26 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; +import { shouldSuppressWorkspaceForLocalArchive } from "./session-workspace-upserts"; + +type WorkspaceDelta = Extract["payload"]; + +export function reconcileWorkspaceDirectory(input: { + serverId: string; + 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); + if (shouldSuppressWorkspaceForLocalArchive({ serverId: input.serverId, workspace })) { + workspaces.delete(workspace.id); + } else { + workspaces.set(workspace.id, workspace); + } + } + } + return workspaces; +} diff --git a/packages/app/src/diagnostics/app-diagnostic-report.test.ts b/packages/app/src/diagnostics/app-diagnostic-report.test.ts index c01244d96..199ca22a6 100644 --- a/packages/app/src/diagnostics/app-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/app-diagnostic-report.test.ts @@ -79,6 +79,7 @@ describe("app diagnostics report", () => { ["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }], ]), clientGeneration: 1, + connectionEpoch: 1, }; const report = formatHostRuntimeSection({ host, snapshot }); diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index 2ef638caf..52fb58e1e 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { useSessionStore } from "@/stores/session-store"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; +import type { HostRuntimeStore } from "@/runtime/host-runtime"; import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization"; import { createSetAgentInitializing, @@ -12,15 +13,28 @@ import { const serverId = "server-1"; const agentId = "agent-1"; -interface FakeDaemonClient { - fetchAgentTimeline: ReturnType; - refreshAgent: ReturnType; +class FakeDaemonClient { + readonly refreshedAgentIds: string[] = []; + + async refreshAgent(requestedAgentId: string): Promise { + this.refreshedAgentIds.push(requestedAgentId); + } } -function makeClient(): FakeDaemonClient { - return { - fetchAgentTimeline: vi.fn().mockResolvedValue(undefined), - refreshAgent: vi.fn().mockResolvedValue(undefined), +class FakeTimelineRuntime { + readonly requests: Array<{ + serverId: string; + agentId: string; + request: Parameters[2]; + }> = []; + + fetchAgentTimeline: HostRuntimeStore["fetchAgentTimeline"] = async ( + requestedServerId, + requestedAgentId, + request, + ) => { + this.requests.push({ serverId: requestedServerId, agentId: requestedAgentId, request }); + return undefined as never; }; } @@ -36,7 +50,8 @@ afterEach(() => { describe("ensureAgentIsInitialized", () => { it("requests bounded projected catch-up after the current cursor when authoritative history is loaded", () => { - const client = makeClient(); + const client = new FakeDaemonClient(); + const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); useSessionStore .getState() @@ -50,46 +65,63 @@ describe("ensureAgentIsInitialized", () => { serverId, agentId, client: client as never, + runtime, setAgentInitializing: bindSetAgentInitializing(), }); - expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { - direction: "after", - cursor: { epoch: "epoch-1", seq: 42 }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); + expect(runtime.requests).toEqual([ + { + serverId, + agentId, + request: { + direction: "after", + cursor: { epoch: "epoch-1", seq: 42 }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "projected", + }, + }, + ]); expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after"); }); it("requests a bounded projected tail when no authoritative cursor is available", () => { - const client = makeClient(); + const client = new FakeDaemonClient(); + const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); void ensureAgentIsInitialized({ serverId, agentId, client: client as never, + runtime, setAgentInitializing: bindSetAgentInitializing(), }); - expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { - direction: "tail", - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); + expect(runtime.requests).toEqual([ + { + serverId, + agentId, + request: { + direction: "tail", + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "projected", + }, + }, + ]); expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail"); }); it("times out initialization after 65 seconds", async () => { vi.useFakeTimers(); - const client = makeClient(); + const client = new FakeDaemonClient(); + const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); const promise = ensureAgentIsInitialized({ serverId, agentId, client: client as never, + runtime, setAgentInitializing: bindSetAgentInitializing(), }); @@ -108,7 +140,8 @@ describe("ensureAgentIsInitialized", () => { it("refreshes the initialization timeout after paged catch-up progress", async () => { vi.useFakeTimers(); - const client = makeClient(); + const client = new FakeDaemonClient(); + const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); const setAgentInitializing = bindSetAgentInitializing(); const key = getInitKey(serverId, agentId); @@ -117,6 +150,7 @@ describe("ensureAgentIsInitialized", () => { serverId, agentId, client: client as never, + runtime, setAgentInitializing, }); @@ -141,20 +175,29 @@ describe("ensureAgentIsInitialized", () => { describe("refreshAgent", () => { it("fetches a bounded projected tail after refreshing the agent", async () => { - const client = makeClient(); + const client = new FakeDaemonClient(); + const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); await refreshAgent({ + serverId, agentId, client: client as never, + runtime, setAgentInitializing: bindSetAgentInitializing(), }); - expect(client.refreshAgent).toHaveBeenCalledWith(agentId); - expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { - direction: "tail", - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); + expect(client.refreshedAgentIds).toEqual([agentId]); + expect(runtime.requests).toEqual([ + { + serverId, + agentId, + request: { + direction: "tail", + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "projected", + }, + }, + ]); }); }); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index b9a76bc99..ae80d352e 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -10,6 +10,7 @@ import { rejectInitDeferred, refreshInitTimeout, } from "@/utils/agent-initialization"; +import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime"; import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { i18n } from "@/i18n/i18next"; @@ -37,6 +38,7 @@ export interface EnsureAgentIsInitializedInput { serverId: string; agentId: string; client: Pick | null; + runtime: Pick; setAgentInitializing: SetAgentInitializing; hostDisconnectedMessage?: string; } @@ -68,7 +70,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): return deferred.promise; } - client.fetchAgentTimeline(agentId, timelineRequest).catch((error) => { + input.runtime.fetchAgentTimeline(serverId, agentId, timelineRequest).catch((error) => { setAgentInitializing(agentId, false); rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error))); }); @@ -77,14 +79,16 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): } export interface RefreshAgentInput { + serverId: string; agentId: string; - client: Pick | null; + client: Pick | null; + runtime: Pick; setAgentInitializing: SetAgentInitializing; hostDisconnectedMessage?: string; } export async function refreshAgent(input: RefreshAgentInput): Promise { - const { agentId, client, setAgentInitializing } = input; + const { serverId, agentId, client, runtime, setAgentInitializing } = input; if (!client) { throw new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")); } @@ -92,7 +96,7 @@ export async function refreshAgent(input: RefreshAgentInput): Promise { try { await client.refreshAgent(agentId); - await client.fetchAgentTimeline(agentId, planTimelineTailFetch()); + await runtime.fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()); } catch (error) { setAgentInitializing(agentId, false); throw error; @@ -135,6 +139,7 @@ export function useAgentInitialization({ serverId, agentId, client, + runtime: getHostRuntimeStore(), setAgentInitializing, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), }), @@ -144,12 +149,14 @@ export function useAgentInitialization({ const refreshAgentCallback = useCallback( (agentId: string): Promise => refreshAgent({ + serverId, agentId, client, + runtime: getHostRuntimeStore(), setAgentInitializing, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), }), - [client, setAgentInitializing, t], + [client, serverId, setAgentInitializing, t], ); return { diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index 775f5f23a..c2cf7c02c 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -4,6 +4,7 @@ import type { ToastApi } from "@/components/toast-host"; import { i18n } from "@/i18n/i18next"; import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store"; import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; export interface LoadOlderAgentHistoryClient { fetchAgentTimeline: ( @@ -97,7 +98,12 @@ export function useLoadOlderAgentHistory({ const loadOlder = useCallback(() => { const session = useSessionStore.getState().sessions[serverId]; void loadOlderAgentHistory(agentId, { - client: (session?.client ?? null) as LoadOlderAgentHistoryClient | null, + client: session?.client + ? { + fetchAgentTimeline: (timelineAgentId, request) => + getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request), + } + : null, cursor: session?.agentTimelineCursor.get(agentId), hasOlder: session?.agentTimelineHasOlder.get(agentId) === true, isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index ba7b899a7..d4102d30c 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -13,6 +13,7 @@ import { useStoreWithEqualityFn } from "zustand/traditional"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { Composer } from "@/composer"; import { AgentModeControl } from "@/composer/agent-controls/mode-control"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; @@ -26,7 +27,7 @@ import { import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; -import { isNative, isWeb } from "@/constants/platform"; +import { isWeb } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft"; @@ -40,10 +41,7 @@ import { import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; -import { - clearHistorySyncErrorAfterSuccessfulSync, - reconcileMissingAgentStateWithPresentAgent, -} from "@/panels/agent-panel-load-state"; +import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; @@ -709,6 +707,7 @@ function ChatAgentContent({ onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); + const isPaneVisible = useRetainedPanelActive(); const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost(); const { isArchivingAgent } = useArchiveAgent(); const streamViewRef = useRef(null); @@ -782,44 +781,6 @@ function ChatAgentContent({ mode: "translate", }); - const handleHistorySyncFailure = useCallback( - ({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => { - if (agentId) { - console.warn("[AgentPanel] history sync failed", { - origin, - agentId, - error, - }); - } - const message = toErrorMessage(error); - setMissingAgentState((previous) => { - if (previous.kind === "error" && previous.message === message) { - return previous; - } - return { kind: "error", message }; - }); - }, - [agentId], - ); - - const ensureInitializedWithSyncErrorHandling = useCallback( - (origin: "focus" | "entry") => { - if (!agentId) { - return; - } - ensureAgentIsInitialized(agentId) - .then(() => { - setMissingAgentState(clearHistorySyncErrorAfterSuccessfulSync); - return undefined; - }) - .catch((error) => { - handleHistorySyncFailure({ origin, error }); - return undefined; - }); - }, - [agentId, ensureAgentIsInitialized, handleHistorySyncFailure], - ); - useEffect(() => { if (connectionStatus === "online") { if (reconnectToastArmedRef.current) { @@ -840,13 +801,6 @@ function ChatAgentContent({ } }, [connectionStatus, dismissToast, toastApi, t]); - useEffect(() => { - if (!isPaneFocused || !agentId || !isConnected || !hasSession) { - return; - } - ensureInitializedWithSyncErrorHandling("focus"); - }, [agentId, ensureInitializedWithSyncErrorHandling, hasSession, isConnected, isPaneFocused]); - const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); useEffect(() => { @@ -946,27 +900,6 @@ function ChatAgentContent({ streamViewRef.current?.scrollToBottom("message-sent"); }, [agentId]); - useEffect(() => { - if (!agentId) { - return; - } - if (!isConnected || !hasSession) { - return; - } - const shouldSyncOnEntry = needsAuthoritativeSync || isNative; - if (!shouldSyncOnEntry) { - return; - } - - ensureInitializedWithSyncErrorHandling("entry"); - }, [ - agentId, - ensureInitializedWithSyncErrorHandling, - hasSession, - isConnected, - needsAuthoritativeSync, - ]); - useEffect(() => { initAttemptTokenRef.current += 1; setMissingAgentState({ kind: "idle" }); @@ -976,16 +909,24 @@ function ChatAgentContent({ if (!agentId) { return; } - if (agentState.id) { - if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { + if (agentState.id && hasAppliedAuthoritativeHistory) { + if ( + missingAgentState.kind === "resolving" || + missingAgentState.kind === "not_found" || + missingAgentState.kind === "error" + ) { setMissingAgentState(reconcileMissingAgentStateWithPresentAgent); } return; } - if (!isConnected || !hasSession) { + if (!isPaneVisible || !isConnected || !hasSession) { return; } - if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { + if ( + missingAgentState.kind === "resolving" || + missingAgentState.kind === "not_found" || + missingAgentState.kind === "error" + ) { return; } @@ -1033,11 +974,13 @@ function ChatAgentContent({ }); }, [ agentState.id, + hasAppliedAuthoritativeHistory, agentId, client, ensureAgentIsInitialized, hasSession, isConnected, + isPaneVisible, missingAgentState.kind, serverId, ]); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts new file mode 100644 index 000000000..4456ecc0a --- /dev/null +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { useSessionStore } from "@/stores/session-store"; +import { AgentDirectoryReplica } from "./agent-replica"; + +function payload(title: string): AgentSnapshotPayload { + return { + id: "agent", + provider: "codex", + cwd: "/repo", + model: null, + createdAt: "2026-07-17T00:00:00.000Z", + updatedAt: "2026-07-17T00:01:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title, + labels: {}, + }; +} + +function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { + return { + agent, + project: { + projectKey: "/repo", + projectName: "repo", + checkout: { + cwd: "/repo", + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }; +} + +describe("AgentDirectoryReplica", () => { + it("keeps membership authoritative across remove, stale timeline, and re-add", () => { + const serverId = "agent-replica"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica(serverId, () => undefined); + replica.commitSnapshot([entry(payload("directory"))], []); + const directoryPlacement = useSessionStore + .getState() + .sessions[serverId]?.agents.get("agent")?.projectPlacement; + expect(directoryPlacement).toBeDefined(); + const staleToken = replica.captureTimeline("agent"); + + replica.remove("agent"); + expect(replica.submitTimelineAgent(staleToken, payload("stale"))).toBe(false); + expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(false); + + replica.applyDelta({ + kind: "upsert", + agent: payload("re-added"), + project: entry(payload("x")).project, + }); + expect(replica.submitTimelineAgent(staleToken, payload("still stale"))).toBe(false); + const currentToken = replica.captureTimeline("agent"); + expect(replica.submitTimelineAgent(currentToken, payload("current"))).toBe(true); + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.title).toBe( + "current", + ); + expect( + useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.projectPlacement, + ).toEqual(directoryPlacement); + store.clearSession(serverId); + }); +}); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts new file mode 100644 index 000000000..62de843cd --- /dev/null +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -0,0 +1,121 @@ +import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; +import { queryClient } from "@/data/query-client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { + applyAgentDirectoryDelta, + type AgentDirectoryDelta, + removeAgentDirectoryReplica, + replaceAgentPendingPermissions, + replaceFetchedAgentDirectory, + upsertAgentReplica, +} from "@/utils/agent-directory-sync"; +import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; +import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; + +export interface AgentLifecycleToken { + readonly agentId: string; + readonly version: number; +} + +export class AgentDirectoryReplica { + private readonly lifecycleVersions = new Map(); + private readonly members = new Set(); + + constructor( + private readonly serverId: string, + private readonly onStoppedRunning: (agentId: string) => void, + ) {} + + captureTimeline(agentId: string): AgentLifecycleToken { + return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 }; + } + + submitTimelineAgent(token: AgentLifecycleToken, payload: AgentSnapshotPayload): boolean { + if ( + !this.members.has(token.agentId) || + token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0) + ) { + return false; + } + const existing = useSessionStore.getState().sessions[this.serverId]?.agents.get(token.agentId); + const timelineAgent = applyLegacyDaemonWorkspaceOwnership({ + serverId: this.serverId, + agent: normalizeAgentSnapshot(payload, this.serverId), + }); + const normalized: Agent = { + ...timelineAgent, + projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement, + }; + const accepted = upsertAgentReplica(this.serverId, normalized); + replaceAgentPendingPermissions(this.serverId, accepted); + useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt); + if (accepted.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id }); + } + return true; + } + + applyDelta(delta: AgentDirectoryDelta): void { + const before = this.members.has(delta.kind === "remove" ? delta.agentId : delta.agent.id); + const result = applyAgentDirectoryDelta({ serverId: this.serverId, delta }); + if (delta.kind === "remove") { + this.members.delete(delta.agentId); + this.advance(delta.agentId); + } else { + this.members.add(delta.agent.id); + if (!before) this.advance(delta.agent.id); + } + if (result.stoppedRunning) this.onStoppedRunning(result.agentId); + } + + commitSnapshot( + entries: FetchAgentsEntry[], + deltas: readonly AgentDirectoryDelta[], + ): Map { + const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); + const reconciled = reconcileAgentDirectory({ previous, snapshot: entries, deltas }); + const nextIds = new Set(reconciled.entries.map((entry) => entry.agent.id)); + for (const agentId of this.members) { + if (!nextIds.has(agentId)) this.advance(agentId); + } + for (const agentId of nextIds) { + if (!this.members.has(agentId)) this.advance(agentId); + } + for (const agentId of previous.keys()) { + if (!nextIds.has(agentId)) removeAgentDirectoryReplica(this.serverId, agentId); + } + this.members.clear(); + for (const agentId of nextIds) this.members.add(agentId); + const { agents } = replaceFetchedAgentDirectory({ + serverId: this.serverId, + entries: reconciled.entries, + }); + for (const agentId of reconciled.stoppedRunningAgentIds) this.onStoppedRunning(agentId); + return agents; + } + + archive(agentId: string, archivedAt: string): void { + this.advance(agentId); + useSessionStore.getState().setAgents(this.serverId, (current) => { + const agent = current.get(agentId); + if (!agent) return current; + const next = new Map(current); + next.set(agentId, { ...agent, archivedAt: new Date(archivedAt) }); + return next; + }); + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); + } + + remove(agentId: string): void { + this.members.delete(agentId); + this.advance(agentId); + removeAgentDirectoryReplica(this.serverId, agentId); + } + + private advance(agentId: string): void { + this.lifecycleVersions.set(agentId, (this.lifecycleVersions.get(agentId) ?? 0) + 1); + } +} diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts new file mode 100644 index 000000000..d610f04be --- /dev/null +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore } from "@/stores/session-store"; +import { DirectoryRefreshSupersededError, DirectorySync } from "./index"; + +class FakeDirectoryClient { + fetchAgentsCalls = 0; + fetchWorkspacesCalls = 0; + + on(): () => void { + return () => undefined; + } + + async fetchAgents(): Promise>> { + this.fetchAgentsCalls += 1; + return { + requestId: "agents", + entries: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }; + } + + async fetchWorkspaces(): Promise>> { + this.fetchWorkspacesCalls += 1; + return { + requestId: "workspaces", + entries: [], + emptyProjects: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }; + } +} + +const serverIds = new Set(); + +function createDirectory(serverId: string): { + client: FakeDirectoryClient; + directory: DirectorySync; +} { + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const directory = new DirectorySync(serverId, { + drainQueuedAgentMessage: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + return { client, directory }; +} + +afterEach(() => { + for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId); + serverIds.clear(); +}); + +describe("DirectorySync session readiness", () => { + it("waits for workspace capability metadata before choosing the workspace protocol", async () => { + const serverId = "workspace-metadata"; + const { client, directory } = createDirectory(serverId); + + const refresh = directory.refreshWorkspaces({ subscribe: true }); + await Promise.resolve(); + expect(client.fetchWorkspacesCalls).toBe(0); + + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + await Promise.resolve(); + expect(client.fetchWorkspacesCalls).toBe(0); + + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + await refresh; + + expect(client.fetchWorkspacesCalls).toBe(1); + expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true); + directory.dispose(); + }); + + it("rejects a session wait on disconnect so the reconnect can refresh", async () => { + const serverId = "session-wait-reconnect"; + const { client, directory } = createDirectory(serverId); + const staleRefresh = directory.refreshAgents(); + await Promise.resolve(); + + directory.connectionChanged({ + client: null, + status: "offline", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + await expect(staleRefresh).rejects.toBeInstanceOf(DirectoryRefreshSupersededError); + + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 2 }, + }); + const currentRefresh = directory.refreshAgents(); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + await currentRefresh; + + expect(client.fetchAgentsCalls).toBe(1); + directory.dispose(); + }); +}); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts new file mode 100644 index 000000000..a181bcd0c --- /dev/null +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -0,0 +1,416 @@ +import type { + DaemonClient, + FetchAgentsEntry, + FetchAgentsOptions, +} from "@getpaseo/client/internal/daemon-client"; +import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once"; +import { + normalizeEmptyProjectDescriptor, + normalizeWorkspaceDescriptor, + useSessionStore, + type Agent, +} from "@/stores/session-store"; +import { + readLegacyDaemonWorkspaceDirectory, + buildLegacyWorkspaces, + shouldUseLegacyDaemonWorkspaceDirectory, + stampLegacyWorkspaceIds, +} from "@/workspace/legacy-daemon-workspaces"; +import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync"; +import { AgentDirectoryReplica } from "./agent-replica"; +import { + WorkspaceDirectoryReplica, + type WorkspaceDirectoryDelta, + type WorkspaceDirectorySnapshot, +} from "./workspace-replica"; +import { + DirectoryTransactionOwner, + type DirectorySourceToken, + type DirectoryTransaction, +} from "./transaction"; + +const PAGE_LIMIT = 200; +const AGENT_SORT: NonNullable = [ + { key: "updated_at", direction: "desc" }, +]; + +interface AgentSnapshot { + entries: FetchAgentsEntry[]; + subscriptionId: string | null; + legacy: boolean; +} + +export interface DirectoryConnection { + client: DaemonClient | null; + status: "online" | "offline"; + source: DirectorySourceToken; +} + +export interface RefreshAgentDirectoryInput { + filter?: FetchAgentsOptions["filter"]; + subscribe?: FetchAgentsOptions["subscribe"]; + page?: FetchAgentsOptions["page"]; +} + +export interface RefreshAgentDirectoryResult { + agents: Map; + subscriptionId: string | null; +} + +export class DirectorySync { + private readonly agentTransactions = new DirectoryTransactionOwner< + AgentSnapshot, + AgentDirectoryDelta + >(); + private readonly workspaceTransactions = new DirectoryTransactionOwner< + WorkspaceDirectorySnapshot, + WorkspaceDirectoryDelta + >(); + private readonly agents: AgentDirectoryReplica; + private readonly workspaces: WorkspaceDirectoryReplica; + private connection: DirectoryConnection = { + client: null, + status: "offline", + source: { clientGeneration: 0, connectionEpoch: 0 }, + }; + private unsubscribe: (() => void) | null = null; + private readonly abortSessionWaits = new Set<() => void>(); + + constructor( + private readonly serverId: string, + private readonly callbacks: { + drainQueuedAgentMessage: (agentId: string) => void; + markAgentLoading: () => void; + markAgentReady: () => void; + markAgentError: (error: string) => void; + }, + ) { + this.agents = new AgentDirectoryReplica(serverId, callbacks.drainQueuedAgentMessage); + this.workspaces = new WorkspaceDirectoryReplica(serverId); + } + + connectionChanged(connection: DirectoryConnection): boolean { + const changed = + this.connection.client !== connection.client || + this.connection.source.clientGeneration !== connection.source.clientGeneration || + this.connection.source.connectionEpoch !== connection.source.connectionEpoch; + const wentOffline = this.connection.status === "online" && connection.status === "offline"; + if (!changed && !wentOffline) { + this.connection = connection; + return false; + } + this.flushAbortedTransactions(); + this.unsubscribe?.(); + this.unsubscribe = null; + this.connection = connection; + this.abortPendingSessionWaits(); + if (!connection.client || connection.status !== "online") return true; + const client = connection.client; + const source = connection.source; + const subscriptions = [ + client.on("agent_update", (message) => { + if (message.type !== "agent_update" || !this.isCurrent(client, source)) return; + if (!this.agentTransactions.record(source, message.payload)) + this.agents.applyDelta(message.payload); + }), + client.on("workspace_update", (message) => { + if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return; + if (!this.workspaceTransactions.record(source, message.payload)) { + this.workspaces.applyDelta(message.payload); + } + }), + client.on("agent_deleted", (message) => { + if (message.type === "agent_deleted" && this.isCurrent(client, source)) { + this.agents.remove(message.payload.agentId); + } + }), + client.on("agent_archived", (message) => { + if (message.type === "agent_archived" && this.isCurrent(client, source)) { + this.agents.archive(message.payload.agentId, message.payload.archivedAt); + } + }), + ]; + this.unsubscribe = () => { + for (const unsubscribe of subscriptions) unsubscribe(); + }; + return true; + } + + dispose(): void { + this.flushAbortedTransactions(); + this.abortPendingSessionWaits(); + this.unsubscribe?.(); + this.unsubscribe = null; + } + + async fetchTimeline( + agentId: string, + request: Parameters[1], + ): Promise>> { + const { client } = this.requireOnline(); + const token = this.agents.captureTimeline(agentId); + const page = await fetchAgentTimelineOnce(client, agentId, request); + if (page.agent) this.agents.submitTimelineAgent(token, page.agent); + return page; + } + + async refreshAgents( + input: RefreshAgentDirectoryInput = {}, + ): Promise { + const { client, source } = this.requireOnline(); + const transaction = this.agentTransactions.begin(source, () => ({ + entries: [], + subscriptionId: null, + legacy: false, + })); + this.callbacks.markAgentLoading(); + try { + await this.waitForSession(client, source); + const session = useSessionStore.getState().sessions[this.serverId]; + if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { + const directory = await readLegacyDaemonWorkspaceDirectory({ + client, + subscribe: input.subscribe, + page: input.page, + }); + if ( + !directory || + !this.agentTransactions.isCurrent(transaction) || + !this.isCurrent(client, source) + ) { + throw new DirectoryRefreshSupersededError("legacy fetch no longer current"); + } + transaction.snapshot.entries.push(...stampLegacyWorkspaceIds(directory.entries)); + transaction.snapshot.subscriptionId = directory.subscriptionId; + transaction.snapshot.legacy = true; + } else { + await this.fetchAgents(client, source, transaction, input); + } + if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) { + throw new DirectoryRefreshSupersededError("agent completion no longer current"); + } + const completion = this.agentTransactions.complete(transaction); + if (completion.kind === "stale") { + throw new DirectoryRefreshSupersededError("agent completion was superseded"); + } + if (completion.snapshot.legacy) { + const store = useSessionStore.getState(); + store.setWorkspaces(this.serverId, buildLegacyWorkspaces(completion.snapshot.entries)); + store.setEmptyProjects(this.serverId, []); + store.setHasHydratedWorkspaces(this.serverId, true); + } + const deltas = completion.snapshot.legacy + ? completion.deltas.map((delta) => + delta.kind === "upsert" + ? { ...delta, agent: { ...delta.agent, workspaceId: delta.agent.cwd } } + : delta, + ) + : completion.deltas; + const agents = this.agents.commitSnapshot(completion.snapshot.entries, deltas); + this.callbacks.markAgentReady(); + return { agents, subscriptionId: completion.snapshot.subscriptionId }; + } catch (error) { + const deltas = this.agentTransactions.fail(transaction); + if (deltas) for (const delta of deltas) this.agents.applyDelta(delta); + if (!(error instanceof DirectoryRefreshSupersededError)) { + this.callbacks.markAgentError(error instanceof Error ? error.message : String(error)); + } + throw error; + } + } + + async refreshWorkspaces(input?: { subscribe?: boolean }): Promise { + const { client, source } = this.requireOnline(); + const transaction = this.workspaceTransactions.begin(source, () => ({ + workspaces: new Map(), + emptyProjects: new Map(), + })); + try { + await this.waitForSessionMetadata(client, source); + const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo; + if (serverInfo?.features?.workspaceMultiplicity !== true) { + const deltas = this.workspaceTransactions.fail(transaction); + if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); + return; + } + await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true); + if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) { + throw new DirectoryRefreshSupersededError("workspace completion no longer current"); + } + const completion = this.workspaceTransactions.complete(transaction); + if (completion.kind === "stale") { + throw new DirectoryRefreshSupersededError("workspace completion was superseded"); + } + this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); + } catch (error) { + const deltas = this.workspaceTransactions.fail(transaction); + if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); + throw error; + } + } + + private async fetchWorkspaceSnapshot( + client: DaemonClient, + source: DirectorySourceToken, + transaction: DirectoryTransaction, + initialSubscribe: boolean, + ): Promise { + let cursor: string | null = null; + let subscribe = initialSubscribe; + while (true) { + const payload = await client.fetchWorkspaces({ + sort: [{ key: "activity_at", direction: "desc" }], + ...(subscribe ? { subscribe: {} } : {}), + page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT }, + }); + this.assertWorkspaceTransactionCurrent(client, source, transaction); + for (const entry of payload.entries) { + const workspace = normalizeWorkspaceDescriptor(entry); + transaction.snapshot.workspaces.set(workspace.id, workspace); + } + for (const entry of payload.emptyProjects ?? []) { + const project = normalizeEmptyProjectDescriptor(entry); + transaction.snapshot.emptyProjects.set(project.projectId, project); + } + if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return; + cursor = payload.pageInfo.nextCursor; + subscribe = false; + } + } + + async refreshAll(): Promise { + await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]); + } + + private async fetchAgents( + client: DaemonClient, + source: DirectorySourceToken, + transaction: DirectoryTransaction, + input: RefreshAgentDirectoryInput, + ): Promise { + let cursor = input.page?.cursor ?? null; + let subscribe = input.subscribe; + while (true) { + const limit = input.page?.limit ?? PAGE_LIMIT; + const payload = await client.fetchAgents({ + ...(input.filter ? { filter: input.filter } : { scope: "active" as const }), + sort: AGENT_SORT, + ...(subscribe ? { subscribe } : {}), + page: cursor ? { limit, cursor } : { limit }, + }); + this.assertAgentTransactionCurrent(client, source, transaction); + transaction.snapshot.entries.push(...payload.entries); + transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null; + const pageInfo = payload.pageInfo as { + hasMore?: boolean; + hasMoreAfter?: boolean; + nextCursor?: string | null; + afterCursor?: string | null; + }; + const hasMore = pageInfo.hasMore ?? pageInfo.hasMoreAfter ?? false; + const nextCursor = pageInfo.nextCursor ?? pageInfo.afterCursor ?? null; + if (!hasMore || !nextCursor) break; + cursor = nextCursor; + subscribe = undefined; + } + } + + private assertAgentTransactionCurrent( + client: DaemonClient, + source: DirectorySourceToken, + transaction: DirectoryTransaction, + ): void { + if (!this.agentTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) { + throw new DirectoryRefreshSupersededError("agent page no longer current"); + } + } + + private assertWorkspaceTransactionCurrent( + client: DaemonClient, + source: DirectorySourceToken, + transaction: DirectoryTransaction, + ): void { + if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) { + throw new DirectoryRefreshSupersededError("workspace fetch no longer current"); + } + } + + private requireOnline(): { client: DaemonClient; source: DirectorySourceToken } { + if (!this.connection.client || this.connection.status !== "online") { + throw new Error(`Host ${this.serverId} is not connected`); + } + return { client: this.connection.client, source: this.connection.source }; + } + + private isCurrent(client: DaemonClient, source: DirectorySourceToken): boolean { + return ( + this.connection.client === client && + this.connection.status === "online" && + this.connection.source.clientGeneration === source.clientGeneration && + this.connection.source.connectionEpoch === source.connectionEpoch + ); + } + + private async waitForSession(client: DaemonClient, source: DirectorySourceToken): Promise { + await this.waitForSessionState(client, source, () => this.hasMatchingSession(client, source)); + } + + private async waitForSessionMetadata( + client: DaemonClient, + source: DirectorySourceToken, + ): Promise { + await this.waitForSessionState(client, source, () => { + const session = useSessionStore.getState().sessions[this.serverId]; + return this.hasMatchingSession(client, source) && session?.serverInfo !== null; + }); + } + + private async waitForSessionState( + client: DaemonClient, + source: DirectorySourceToken, + matches: () => boolean, + ): Promise { + if (matches()) return; + await new Promise((resolve, reject) => { + let settled = false; + let unsubscribe: () => void = () => undefined; + const finish = (result: "ready" | "aborted") => { + if (settled) return; + settled = true; + unsubscribe(); + this.abortSessionWaits.delete(abort); + if (result === "ready") resolve(); + else reject(new DirectoryRefreshSupersededError("session wait no longer current")); + }; + const abort = () => finish("aborted"); + const check = () => { + if (matches()) { + finish("ready"); + } else if (!this.isCurrent(client, source)) { + finish("aborted"); + } + }; + this.abortSessionWaits.add(abort); + unsubscribe = useSessionStore.subscribe(check); + check(); + }); + } + + private hasMatchingSession(client: DaemonClient, source: DirectorySourceToken): boolean { + const session = useSessionStore.getState().sessions[this.serverId]; + return session?.client === client && session.clientGeneration === source.clientGeneration; + } + + private flushAbortedTransactions(): void { + for (const delta of this.agentTransactions.abort()) this.agents.applyDelta(delta); + for (const delta of this.workspaceTransactions.abort()) this.workspaces.applyDelta(delta); + } + + private abortPendingSessionWaits(): void { + for (const abort of this.abortSessionWaits) abort(); + } +} + +export class DirectoryRefreshSupersededError extends Error {} + +export type { DirectorySourceToken } from "./transaction"; diff --git a/packages/app/src/runtime/directory-sync/transaction.test.ts b/packages/app/src/runtime/directory-sync/transaction.test.ts new file mode 100644 index 000000000..bc8002704 --- /dev/null +++ b/packages/app/src/runtime/directory-sync/transaction.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { DirectoryTransactionOwner, type DirectorySourceToken } from "./transaction"; + +const epoch = (connectionEpoch: number): DirectorySourceToken => ({ + clientGeneration: 1, + connectionEpoch, +}); + +type Event = "begin-new" | "first" | "second" | "finish-old" | "finish-new"; + +function permutations(events: Event[]): Event[][] { + if (events.length === 0) return [[]]; + return events.flatMap((event, index) => + permutations(events.filter((_, candidate) => candidate !== index)).map((tail) => + [event].concat(tail), + ), + ); +} + +describe("DirectoryTransactionOwner", () => { + it("applies every bounded two-refresh interleaving exactly once", () => { + const schedules = permutations([ + "begin-new", + "first", + "second", + "finish-old", + "finish-new", + ]).filter((events) => events.indexOf("begin-new") < events.indexOf("finish-new")); + expect(schedules).toHaveLength(60); + for (const events of schedules) { + const owner = new DirectoryTransactionOwner(); + const old = owner.begin(epoch(1), () => null); + let current = old; + const applied: string[] = []; + for (const event of events) { + if (event === "begin-new") current = owner.begin(epoch(1), () => null); + if (event === "first" || event === "second") { + if (!owner.record(epoch(1), event)) applied.push(event); + } + if (event === "finish-old") { + const result = owner.complete(old); + if (result.kind === "current") applied.push(...result.deltas); + } + if (event === "finish-new") { + const result = owner.complete(current); + if (result.kind === "current") applied.push(...result.deltas); + } + } + expect(applied.sort(), JSON.stringify(events)).toEqual(["first", "second"]); + } + }); + + it.each([ + ["success", false, false, ["delta"]], + ["failure", true, false, ["delta"]], + ["superseded success", false, true, ["delta"]], + ["superseded failure", true, true, ["delta"]], + ] as const)("preserves buffered deltas through %s", (_name, fail, supersede, expected) => { + const owner = new DirectoryTransactionOwner(); + const first = owner.begin(epoch(1), () => []); + owner.record(epoch(1), "delta"); + const current = supersede ? owner.begin(epoch(1), () => []) : first; + + let deltas: readonly string[] | null; + if (fail) { + deltas = owner.fail(current); + } else { + const completion = owner.complete(current); + deltas = completion.kind === "current" ? completion.deltas : null; + } + + expect(deltas).toEqual(expected); + if (supersede) expect(owner.complete(first)).toEqual({ kind: "stale" }); + }); + + it("makes a stale completion inert", () => { + const owner = new DirectoryTransactionOwner(); + const first = owner.begin(epoch(1), () => null); + const second = owner.begin(epoch(1), () => null); + expect(owner.complete(first)).toEqual({ kind: "stale" }); + expect(owner.isCurrent(second)).toBe(true); + }); + + it("isolates reconnect epochs", () => { + const owner = new DirectoryTransactionOwner(); + const disconnected = owner.begin(epoch(1), () => null); + owner.record(epoch(1), "old"); + + expect(owner.abort()).toEqual(["old"]); + const reconnected = owner.begin(epoch(2), () => null); + owner.record(epoch(2), "new"); + + expect(owner.complete(disconnected)).toEqual({ kind: "stale" }); + expect(owner.complete(reconnected)).toEqual({ + kind: "current", + snapshot: null, + deltas: ["new"], + }); + }); +}); diff --git a/packages/app/src/runtime/directory-sync/transaction.ts b/packages/app/src/runtime/directory-sync/transaction.ts new file mode 100644 index 000000000..15851491c --- /dev/null +++ b/packages/app/src/runtime/directory-sync/transaction.ts @@ -0,0 +1,70 @@ +export interface DirectorySourceToken { + clientGeneration: number; + connectionEpoch: number; +} + +export interface DirectoryTransaction { + readonly source: DirectorySourceToken; + readonly snapshot: TSnapshot; + readonly deltas: TDelta[]; +} + +export type DirectoryTransactionCompletion = + | { kind: "stale" } + | { kind: "current"; snapshot: TSnapshot; deltas: readonly TDelta[] }; + +function isSameSource(left: DirectorySourceToken, right: DirectorySourceToken): boolean { + return ( + left.clientGeneration === right.clientGeneration && + left.connectionEpoch === right.connectionEpoch + ); +} + +/** Owns the shared begin/record/supersede/complete lifecycle for a directory replica. */ +export class DirectoryTransactionOwner { + private current: DirectoryTransaction | null = null; + + begin( + source: DirectorySourceToken, + createSnapshot: () => TSnapshot, + ): DirectoryTransaction { + const transaction: DirectoryTransaction = { + source, + snapshot: createSnapshot(), + deltas: + this.current && isSameSource(this.current.source, source) ? [...this.current.deltas] : [], + }; + this.current = transaction; + return transaction; + } + + record(source: DirectorySourceToken, delta: TDelta): boolean { + if (!this.current || !isSameSource(this.current.source, source)) return false; + this.current.deltas.push(delta); + return true; + } + + isCurrent(transaction: DirectoryTransaction): boolean { + return this.current === transaction; + } + + complete( + transaction: DirectoryTransaction, + ): DirectoryTransactionCompletion { + if (this.current !== transaction) return { kind: "stale" }; + this.current = null; + return { kind: "current", snapshot: transaction.snapshot, deltas: transaction.deltas }; + } + + fail(transaction: DirectoryTransaction): readonly TDelta[] | null { + const completion = this.complete(transaction); + return completion.kind === "current" ? completion.deltas : null; + } + + abort(): readonly TDelta[] { + if (!this.current) return []; + const deltas = [...this.current.deltas]; + this.current = null; + return deltas; + } +} diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts new file mode 100644 index 000000000..48b55c002 --- /dev/null +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -0,0 +1,57 @@ +import { expect, it } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; +import { + normalizeEmptyProjectDescriptor, + normalizeWorkspaceDescriptor, + useSessionStore, +} from "@/stores/session-store"; +import { WorkspaceDirectoryReplica } from "./workspace-replica"; + +function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload { + return { + id, + projectId, + projectDisplayName: projectId, + projectRootPath: `/repo/${projectId}`, + workspaceDirectory: `/repo/${projectId}/${id}`, + projectKind: "git", + workspaceKind: "worktree", + name: id, + title: id, + status: "done", + activityAt: null, + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }; +} + +it("commits workspace and project-parent state with filtered removals", () => { + const serverId = "workspace-replica"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const empty = normalizeEmptyProjectDescriptor({ + projectId: "empty", + projectDisplayName: "Empty", + projectRootPath: "/repo/empty", + projectKind: "git", + }); + replica.commitSnapshot( + { + workspaces: new Map([ + ["kept", normalizeWorkspaceDescriptor(workspace("kept"))], + ["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))], + ]), + emptyProjects: new Map([[empty.projectId, empty]]), + }, + [{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }], + ); + + const session = useSessionStore.getState().sessions[serverId]; + expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["kept"]); + expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]); + store.clearSession(serverId); +}); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts new file mode 100644 index 000000000..f2b69c853 --- /dev/null +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -0,0 +1,93 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { + normalizeEmptyProjectDescriptor, + normalizeWorkspaceDescriptor, + useSessionStore, + type EmptyProjectDescriptor, + type WorkspaceDescriptor, +} from "@/stores/session-store"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; +import { + clearWorkspaceArchivePending, + shouldSuppressWorkspaceForLocalArchive, +} from "@/contexts/session-workspace-upserts"; + +export type WorkspaceDirectoryDelta = Extract< + SessionOutboundMessage, + { type: "workspace_update" } +>["payload"]; + +export interface WorkspaceDirectorySnapshot { + workspaces: Map; + emptyProjects: Map; +} + +export class WorkspaceDirectoryReplica { + constructor(private readonly serverId: string) {} + + applyDelta(delta: WorkspaceDirectoryDelta): void { + const state = this.reconcile(this.read(), [delta]); + this.commit(state, delta.kind === "remove" ? [delta.id] : []); + } + + commitSnapshot( + snapshot: WorkspaceDirectorySnapshot, + deltas: readonly WorkspaceDirectoryDelta[], + ): void { + const removedWorkspaceIds = deltas.flatMap((delta) => + delta.kind === "remove" ? [delta.id] : [], + ); + this.commit(this.reconcile(snapshot, deltas), removedWorkspaceIds); + } + + private read(): WorkspaceDirectorySnapshot { + const session = useSessionStore.getState().sessions[this.serverId]; + return { + workspaces: new Map(session?.workspaces), + emptyProjects: new Map(session?.emptyProjects), + }; + } + + private reconcile( + snapshot: WorkspaceDirectorySnapshot, + deltas: readonly WorkspaceDirectoryDelta[], + ): WorkspaceDirectorySnapshot { + const workspaces = new Map(snapshot.workspaces); + const emptyProjects = new Map(snapshot.emptyProjects); + for (const [workspaceId, workspace] of workspaces) { + if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { + workspaces.delete(workspaceId); + } + } + for (const delta of deltas) { + if (delta.kind === "remove") { + workspaces.delete(delta.id); + if (delta.emptyProject) { + const project = normalizeEmptyProjectDescriptor(delta.emptyProject); + emptyProjects.set(project.projectId, project); + } + if (delta.removedProjectId) emptyProjects.delete(delta.removedProjectId); + continue; + } + const workspace = normalizeWorkspaceDescriptor(delta.workspace); + if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { + workspaces.delete(workspace.id); + } else { + workspaces.set(workspace.id, workspace); + emptyProjects.delete(workspace.projectId); + } + } + return { workspaces, emptyProjects }; + } + + private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void { + const store = useSessionStore.getState(); + store.setWorkspaces(this.serverId, snapshot.workspaces); + store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values()); + store.setHasHydratedWorkspaces(this.serverId, true); + for (const workspaceId of removedWorkspaceIds) { + clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId }); + useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId }); + } + } +} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index f9424e195..4de541e34 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,45 @@ class FakeDaemonClient { private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = []; public connectCalls = 0; public fetchAgentsCalls: FetchAgentsOptions[] = []; - public fetchAgentsResponses: Awaited>[] = []; + public fetchAgentsResponses: Array< + Awaited> | ReturnType + > = []; + public sentAgentMessages: Array> = []; + public sendAgentMessageFailures: Error[] = []; + public sendAgentMessageResponses: Promise[] = []; + 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 +79,27 @@ 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(); + const response = this.sendAgentMessageResponses.shift(); + if (response) await response; + const failure = this.sendAgentMessageFailures.shift(); + if (failure) throw failure; + } + + 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 +126,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 +137,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 +229,38 @@ function makeFetchAgentsPayload(input: { }; } +class Deferred { + readonly promise: Promise; + private resolvePromise!: (value: T) => void; + private rejectPromise!: (error: Error) => void; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + }); + } + + resolve(value: T): void { + this.resolvePromise(value); + } + + reject(error: Error): void { + this.rejectPromise(error); + } +} + +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 +321,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", @@ -524,7 +633,6 @@ describe("HostRuntimeController", () => { }); await controller.start({ autoProbe: false }); - const snapshot = controller.getSnapshot(); expect(snapshot.activeConnectionId).toBe("direct:lan:6767"); expect(snapshot.connectionStatus).toBe("online"); @@ -1005,6 +1113,7 @@ describe("HostRuntimeController", () => { }); await controller.start({ autoProbe: false }); + const firstEpoch = controller.getSnapshot().connectionEpoch; controller.markAgentDirectorySyncReady(); expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready"); expect(controller.getSnapshot().hasEverLoadedAgentDirectory).toBe(true); @@ -1019,6 +1128,7 @@ describe("HostRuntimeController", () => { clients[0]?.setConnectionState({ status: "connected" }); expect(controller.getSnapshot().connectionStatus).toBe("online"); expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready"); + expect(controller.getSnapshot().connectionEpoch).toBe(firstEpoch + 1); }); it("stores directory sync errors as non-blocking after a successful directory load", async () => { @@ -1322,13 +1432,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 +1450,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 +1488,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 +1506,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 +1548,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 +1556,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 +1580,101 @@ 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", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + 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 +1730,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 +1749,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 +1763,744 @@ 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", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + 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", + }); + const recoveredAgent = makeFetchAgentsEntry({ + id: "agent-recovered", + cwd: "/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + title: "before refresh", + }); + 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", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setAgents( + host.serverId, + new Map([[recoveredAgent.agent.id, replicaAgent(recoveredAgent.agent, host.serverId)]]), + ); + sessionStore.setAgentTimelineCursor( + host.serverId, + new Map([[recoveredAgent.agent.id, { epoch: "epoch", startSeq: 10, endSeq: 20 }]]), + ); + store.syncHosts([host]); + await fakeClient.waitForFetches(2); + + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...pageOneAgent.agent, title: "live" }, + project: pageOneAgent.project, + }); + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...recoveredAgent.agent, title: "recovered by delta" }, + project: recoveredAgent.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], + ["agent-recovered", "recovered by delta"], + ]); + expect( + useSessionStore + .getState() + .sessions[host.serverId]?.agentTimelineCursor.get(recoveredAgent.agent.id), + ).toEqual({ epoch: "epoch", startSeq: 10, endSeq: 20 }); + + 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", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + 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, + }); + const generation = store.getSnapshot(host.serverId)?.clientGeneration ?? 0; + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, generation); + useSessionStore.getState().updateSessionServerInfo(host.serverId, { + serverId: host.serverId, + hostname: null, + version: "test", + }); + 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("restarts directory bootstrap when reconnect supersedes a pending session wait", async () => { + const host = makeHost({ + serverId: "srv_session_wait_reconnect", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + 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_session_wait_reconnect", + }, + }); + + store.syncHosts([host]); + await fakeClient.waitForAgentUpdates(); + fakeClient.setConnectionState({ status: "disconnected", reason: "network" }); + fakeClient.setConnectionState({ status: "connected" }); + + const generation = store.getSnapshot(host.serverId)?.clientGeneration ?? 0; + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession( + host.serverId, + fakeClient as unknown as DaemonClient, + generation, + ); + sessionStore.updateSessionServerInfo(host.serverId, { + serverId: host.serverId, + hostname: null, + version: "test", + }); + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); + + expect(fakeClient.fetchAgentsCalls).toHaveLength(1); + store.syncHosts([]); + sessionStore.clearSession(host.serverId); + }); + + it("rejects a superseded refresh without overwriting the newer replica", async () => { + const host = makeHost({ + serverId: "srv_overlap", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + 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("replays inherited deltas when a superseding refresh fails", async () => { + const host = makeHost({ + serverId: "srv_overlap_failure", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + 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_failure", + }, + }); + 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 liveEntry = makeFetchAgentsEntry({ + id: "live-delta", + cwd: "/repo", + updatedAt: "2026-07-17T10:00:00.000Z", + title: "preserved", + }); + fakeClient.agentUpdate({ kind: "upsert", agent: liveEntry.agent, project: liveEntry.project }); + + const newerPage = new Deferred>>(); + fakeClient.fetchAgentsResponses.push(newerPage.promise); + const newerRefresh = store.refreshAgentDirectory({ serverId: host.serverId }); + await fakeClient.waitForFetches(3); + newerPage.reject(new Error("newer refresh failed")); + await expect(newerRefresh).rejects.toThrow("newer refresh failed"); + + expect( + useSessionStore.getState().sessions[host.serverId]?.agents.get("live-delta")?.title, + ).toBe("preserved"); + + olderPage.resolve(makeFetchAgentsPayload({ entries: [] })); + await expect(olderRefresh).rejects.toThrow(); + 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", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + 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", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + 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("restores an automatically drained message when sending fails", async () => { + const host = makeHost({ serverId: "srv_failed_queue_drain" }); + const fakeClient = new FakeDaemonClient(); + fakeClient.sendAgentMessageFailures.push(new Error("connection lost")); + 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_failed_queue_drain", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setQueuedMessages( + host.serverId, + new Map([ + [ + "agent", + [ + { id: "first", text: "retry me", attachments: [] }, + { id: "second", text: "keep me behind", attachments: [] }, + ], + ], + ]), + ); + + store.drainQueuedAgentMessage(host.serverId, "agent"); + + await vi.waitFor(() => { + expect(fakeClient.sentAgentMessages).toHaveLength(1); + expect( + useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get("agent"), + ).toEqual([ + { id: "first", text: "retry me", attachments: [] }, + { id: "second", text: "keep me behind", attachments: [] }, + ]); + }); + + useSessionStore.getState().clearSession(host.serverId); + }); + + it("serializes queued-message drains for the same agent", async () => { + const host = makeHost({ serverId: "srv_serialized_queue_drain" }); + const fakeClient = new FakeDaemonClient(); + const send = new Deferred(); + fakeClient.sendAgentMessageResponses.push(send.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_serialized_queue_drain", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setQueuedMessages( + host.serverId, + new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]), + ); + + store.drainQueuedAgentMessage(host.serverId, "agent"); + store.drainQueuedAgentMessage(host.serverId, "agent"); + await fakeClient.waitForSentMessages(1); + expect(fakeClient.sentAgentMessages).toHaveLength(1); + + send.resolve(); + await vi.waitFor(() => { + expect( + useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get("agent"), + ).toEqual([]); + }); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => { + const host = makeHost({ serverId: "srv_legacy_queue_attachment" }); + const fakeClient = new FakeDaemonClient(); + 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_queue_attachment", + }, + }); + 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.105", + features: { forgeSearch: false }, + }); + sessionStore.setQueuedMessages( + host.serverId, + new Map([ + [ + "agent", + [ + { + id: "queued-legacy-attachment", + text: "review this", + attachments: [ + { + kind: "github_pr" as const, + item: { + kind: "change_request" as const, + number: 42, + title: "Compatibility fix", + url: "https://github.com/acme/repo/pull/42", + state: "open" as const, + body: "Details", + labels: [], + baseRefName: "main", + headRefName: "fix", + }, + }, + ], + }, + ], + ], + ]), + ); + + store.drainQueuedAgentMessage(host.serverId, "agent"); + await fakeClient.waitForSentMessages(1); + + expect(fakeClient.sentAgentMessages[0]?.[2]?.attachments).toEqual([ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 42, + title: "Compatibility fix", + url: "https://github.com/acme/repo/pull/42", + body: "Details", + baseRefName: "main", + headRefName: "fix", + }, + ]); + sessionStore.clearSession(host.serverId); + }); + + it("applies buffered stale side effects from the accepted page agent", async () => { + const host = makeHost({ + serverId: "srv_buffered_stale_side_effects", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + 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 +2528,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 +2545,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 +2600,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 +2625,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..85f4634ec 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -4,7 +4,6 @@ import equal from "fast-deep-equal/es6"; import { DaemonClient, type ConnectionState, - type FetchAgentsEntry, type FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; import { @@ -40,12 +39,7 @@ import { import { getDesktopHost } from "@/desktop/host"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; -import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { useSessionStore } from "@/stores/session-store"; -import { - fetchLegacyDaemonWorkspaceDirectory, - shouldUseLegacyDaemonWorkspaceDirectory, -} from "@/workspace/legacy-daemon-workspaces"; import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys"; import { queryClient } from "@/data/query-client"; import { @@ -54,6 +48,13 @@ import { } from "@/data/push-router"; import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler"; import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules"; +import { sendQueuedComposerMessageNow } from "@/composer/actions"; +import { + resolveComposerAttachmentSubmitFormat, + splitComposerAttachmentsForSubmit, +} from "@/composer/attachments/submit"; +import { encodeImages } from "@/utils/encode-images"; +import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; export type HostRegistryStatus = "loading" | "ready"; @@ -85,6 +86,7 @@ export interface HostRuntimeSnapshot { hasEverLoadedAgentDirectory: boolean; probeByConnectionId: Map; clientGeneration: number; + connectionEpoch: number; } type HostRuntimeSnapshotPatch = Partial>; @@ -176,90 +178,6 @@ const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3; const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200; const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000; -const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable = [ - { key: "updated_at", direction: "desc" }, -]; - -function readFetchAgentsHasMore( - pageInfo: Awaited>["pageInfo"], -): boolean { - const page = pageInfo as { - hasMore?: boolean; - hasMoreAfter?: boolean; - }; - if (typeof page.hasMore === "boolean") { - return page.hasMore; - } - if (typeof page.hasMoreAfter === "boolean") { - return page.hasMoreAfter; - } - return false; -} - -function readFetchAgentsNextCursor( - pageInfo: Awaited>["pageInfo"], -): string | null { - const page = pageInfo as { - nextCursor?: string | null; - afterCursor?: string | null; - }; - if (typeof page.nextCursor === "string" && page.nextCursor.length > 0) { - return page.nextCursor; - } - if (typeof page.afterCursor === "string" && page.afterCursor.length > 0) { - return page.afterCursor; - } - return null; -} - -interface AgentDirectoryFetchInput { - client: DaemonClient; - filter?: FetchAgentsOptions["filter"]; - subscribe?: FetchAgentsOptions["subscribe"]; - page?: FetchAgentsOptions["page"]; -} - -interface AgentDirectoryFetchResult { - entries: FetchAgentsEntry[]; - subscriptionId: string | null; -} - -async function fetchCurrentAgentDirectory( - input: AgentDirectoryFetchInput, -): Promise { - const pageLimit = input.page?.limit ?? DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT; - let cursor = input.page?.cursor ?? null; - let includeSubscribe = true; - let subscriptionId: string | null = null; - const entries: FetchAgentsEntry[] = []; - - while (true) { - const payload = await input.client.fetchAgents({ - scope: input.filter ? undefined : "active", - ...(input.filter ? { filter: input.filter } : {}), - sort: DEFAULT_AGENT_DIRECTORY_SORT, - ...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}), - page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit }, - }); - - entries.push(...payload.entries); - subscriptionId = subscriptionId ?? payload.subscriptionId ?? null; - includeSubscribe = false; - - if (!readFetchAgentsHasMore(payload.pageInfo)) { - break; - } - - const nextCursor = readFetchAgentsNextCursor(payload.pageInfo); - if (!nextCursor) { - break; - } - cursor = nextCursor; - } - - return { entries, subscriptionId }; -} - function toActiveConnection(connection: HostConnection): ActiveConnection { if (connection.type === "directSocket") { return { @@ -445,9 +363,15 @@ function nextConnectionMachineState(input: { function toSnapshotConnectionPatch( state: HostRuntimeConnectionMachineState, + connectionEpoch: number, ): Pick< HostRuntimeSnapshot, - "activeConnectionId" | "activeConnection" | "connectionStatus" | "lastError" | "lastOnlineAt" + | "activeConnectionId" + | "activeConnection" + | "connectionStatus" + | "lastError" + | "lastOnlineAt" + | "connectionEpoch" > { if (state.tag === "booting") { return { @@ -456,6 +380,7 @@ function toSnapshotConnectionPatch( connectionStatus: "connecting", lastError: null, lastOnlineAt: null, + connectionEpoch, }; } if (state.tag === "connecting") { @@ -465,6 +390,7 @@ function toSnapshotConnectionPatch( connectionStatus: "connecting", lastError: null, lastOnlineAt: null, + connectionEpoch, }; } if (state.tag === "online") { @@ -474,6 +400,7 @@ function toSnapshotConnectionPatch( connectionStatus: "online", lastError: null, lastOnlineAt: state.lastOnlineAt, + connectionEpoch, }; } if (state.tag === "offline") { @@ -483,6 +410,7 @@ function toSnapshotConnectionPatch( connectionStatus: "offline", lastError: null, lastOnlineAt: null, + connectionEpoch, }; } return { @@ -491,6 +419,7 @@ function toSnapshotConnectionPatch( connectionStatus: "error", lastError: state.message, lastOnlineAt: null, + connectionEpoch, }; } @@ -538,6 +467,10 @@ function createDefaultDeps(): HostRuntimeControllerDeps { }, } : undefined; + const appCapabilities = { + [CLIENT_CAPS.selectiveAgentTimeline]: true, + ...browserAutomationCapabilities, + }; return { createClient: ({ host, connection, clientId, runtimeGeneration }) => { @@ -548,7 +481,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { clientType: "mobile" as const, appVersion: resolveAppVersion() ?? undefined, runtimeGeneration, - ...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}), + capabilities: appCapabilities, }; if (connection.type === "directSocket" || connection.type === "directPipe") { return new DaemonClient({ @@ -586,7 +519,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { connectToDaemon(connection, { ...(host.serverId ? { serverId: host.serverId } : {}), ...(timeoutMs !== undefined ? { timeoutMs } : {}), - ...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}), + capabilities: appCapabilities, }), getClientId: () => getOrCreateClientId(), mountClientHandlers: ({ client, host }) => { @@ -614,6 +547,7 @@ export class HostRuntimeController { private deps: HostRuntimeControllerDeps; private onReconcileServerId: ((oldId: string, newId: string) => void) | null; private connectionMachineState: HostRuntimeConnectionMachineState; + private connectionEpoch = 0; private snapshot: HostRuntimeSnapshot; private listeners = new Set<() => void>(); private activeClient: DaemonClient | null = null; @@ -644,7 +578,7 @@ export class HostRuntimeController { }; this.snapshot = { serverId: this.host.serverId, - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), client: null, agentDirectoryStatus: "idle", agentDirectoryError: null, @@ -712,7 +646,7 @@ export class HostRuntimeController { } this.applyConnectionEvent({ type: "stopped" }); this.updateSnapshot({ - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), client: null, }); } @@ -776,7 +710,7 @@ export class HostRuntimeController { markStartupError(message: string): void { this.applyConnectionEvent({ type: "connect_failed", message }); this.updateSnapshot({ - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), }); } @@ -809,7 +743,7 @@ export class HostRuntimeController { } this.applyConnectionEvent({ type: "no_connections" }); this.updateSnapshot({ - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), probeByConnectionId: new Map(), }); return; @@ -1095,6 +1029,9 @@ export class HostRuntimeController { state: previousState, event, }); + if (previousState.tag !== "online" && nextState.tag === "online") { + this.connectionEpoch += 1; + } this.connectionMachineState = nextState; this.logConnectionTransition({ from: previousState.tag, @@ -1171,7 +1108,7 @@ export class HostRuntimeController { message: `Failed to resolve client id: ${message}`, }); this.updateSnapshot({ - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), }); return null; } @@ -1269,7 +1206,7 @@ export class HostRuntimeController { this.snapshot = { ...this.snapshot, serverId: this.host.serverId, - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), client, clientGeneration: nextGeneration, }; @@ -1287,7 +1224,7 @@ export class HostRuntimeController { lastError: client.lastError, }); const patch: HostRuntimeSnapshotPatch = { - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), ...this.buildAgentDirectoryStatusPatch(), }; this.updateSnapshot(patch); @@ -1307,7 +1244,7 @@ export class HostRuntimeController { message, }); this.updateSnapshot({ - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), }); } } @@ -1393,6 +1330,13 @@ function rekeyMap(map: Map, oldKey: string, newKey: string): void map.set(newKey, value); } +interface AgentDirectoryRefreshInput { + serverId: string; + filter?: FetchAgentsOptions["filter"]; + subscribe?: FetchAgentsOptions["subscribe"]; + page?: FetchAgentsOptions["page"]; +} + export class HostRuntimeStore { private controllers = new Map(); private serverListeners = new Map void>>(); @@ -1406,6 +1350,8 @@ export class HostRuntimeStore { private deps: HostRuntimeControllerDeps; private lastConnectionStatusByServer = new Map(); private agentDirectoryBootstrapInFlight = new Map>(); + private queuedAgentDrainInFlight = new Set(); + private directorySyncByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootStarted = false; private storage: HostRuntimeStorage; @@ -1636,6 +1582,27 @@ export class HostRuntimeStore { rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId); rekeyMap(this.agentDirectoryBootstrapInFlight, oldServerId, newServerId); + this.directorySyncByServer.get(oldServerId)?.dispose(); + this.directorySyncByServer.delete(oldServerId); + const directory = new DirectorySync(newServerId, { + drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId), + markAgentLoading: () => controller.markAgentDirectorySyncLoading(), + markAgentReady: () => { + this.agentDirectoryBootstrapInFlight.delete(newServerId); + controller.markAgentDirectorySyncReady(); + }, + markAgentError: (error) => controller.markAgentDirectorySyncError(error), + }); + this.directorySyncByServer.set(newServerId, directory); + const snapshot = controller.getSnapshot(); + directory.connectionChanged({ + client: snapshot.client, + status: snapshot.connectionStatus === "online" ? "online" : "offline", + source: { + clientGeneration: snapshot.clientGeneration, + connectionEpoch: snapshot.connectionEpoch, + }, + }); const listeners = this.serverListeners.get(oldServerId); if (listeners) { @@ -1926,6 +1893,8 @@ export class HostRuntimeStore { this.controllers.delete(serverId); this.lastConnectionStatusByServer.delete(serverId); this.agentDirectoryBootstrapInFlight.delete(serverId); + this.directorySyncByServer.get(serverId)?.dispose(); + this.directorySyncByServer.delete(serverId); void controller.stop(); this.emit(serverId); } @@ -1948,6 +1917,19 @@ export class HostRuntimeStore { onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), }); this.controllers.set(host.serverId, controller); + this.directorySyncByServer.set( + host.serverId, + new DirectorySync(host.serverId, { + drainQueuedAgentMessage: (agentId) => + this.drainQueuedAgentMessage(host.serverId, agentId), + markAgentLoading: () => controller.markAgentDirectorySyncLoading(), + markAgentReady: () => { + this.agentDirectoryBootstrapInFlight.delete(host.serverId); + controller.markAgentDirectorySyncReady(); + }, + markAgentError: (error) => controller.markAgentDirectorySyncError(error), + }), + ); this.lastConnectionStatusByServer.set( host.serverId, controller.getSnapshot().connectionStatus, @@ -1980,6 +1962,15 @@ export class HostRuntimeStore { return; } const snapshot = controller.getSnapshot(); + const directorySourceChanged = + this.directorySyncByServer.get(serverId)?.connectionChanged({ + client: snapshot.client, + status: snapshot.connectionStatus === "online" ? "online" : "offline", + source: { + clientGeneration: snapshot.clientGeneration, + connectionEpoch: snapshot.connectionEpoch, + }, + }) ?? false; const previousStatus = this.lastConnectionStatusByServer.get(serverId); this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus); const didTransitionOnline = @@ -2002,17 +1993,20 @@ export class HostRuntimeStore { if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) { return; } - if (this.agentDirectoryBootstrapInFlight.has(serverId)) { + if (this.agentDirectoryBootstrapInFlight.has(serverId) && !directorySourceChanged) { return; } const bootstrap = Promise.resolve() .then(() => - this.refreshAgentDirectory({ - serverId, - subscribe: { subscriptionId: `app:${serverId}` }, - page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT }, - }), + Promise.all([ + this.refreshAgentDirectory({ + serverId, + subscribe: { subscriptionId: `app:${serverId}` }, + page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT }, + }), + this.refreshWorkspaceDirectory({ serverId, subscribe: true }), + ]), ) .then(() => undefined) .catch((error) => { @@ -2031,6 +2025,55 @@ export class HostRuntimeStore { this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap); } + drainQueuedAgentMessage(serverId: string, agentId: string): void { + const drainKey = `${serverId}:${agentId}`; + if (this.queuedAgentDrainInFlight.has(drainKey)) return; + const store = useSessionStore.getState(); + const session = store.sessions[serverId]; + const queue = session?.queuedMessages.get(agentId); + const client = session?.client; + if (!client || !queue?.length || session.initializingAgents.get(agentId) === true) { + return; + } + this.queuedAgentDrainInFlight.add(drainKey); + const next = queue[0]; + void sendQueuedComposerMessageNow({ + agentId, + messageId: next.id, + queue: { + read: (queuedAgentId) => + useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [], + write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update), + }, + submitMessage: async ({ text, attachments }) => { + const supportsForgeAttachments = + useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true; + const wirePayload = splitComposerAttachmentsForSubmit(attachments, { + format: resolveComposerAttachmentSubmitFormat({ supportsForgeAttachments }), + }); + const images = await encodeImages(wirePayload.images); + await client.sendAgentMessage(agentId, text, { + messageId: next.id, + ...(images && images.length > 0 ? { images } : {}), + attachments: wirePayload.attachments, + }); + }, + }) + .then((result) => { + if (result.status === "failed") { + console.error("[HostRuntime] failed to drain queued agent message", { + serverId, + agentId, + error: result.errorMessage, + }); + } + return result; + }) + .finally(() => { + this.queuedAgentDrainInFlight.delete(drainKey); + }); + } + getSnapshot(serverId: string): HostRuntimeSnapshot | null { return this.controllers.get(serverId)?.getSnapshot() ?? null; } @@ -2095,63 +2138,34 @@ export class HostRuntimeStore { ).then(() => undefined); } - async refreshAgentDirectory(input: { - serverId: string; - filter?: FetchAgentsOptions["filter"]; - subscribe?: FetchAgentsOptions["subscribe"]; - page?: FetchAgentsOptions["page"]; - }): Promise<{ - agents: ReturnType["agents"]; - subscriptionId: string | null; - }> { - const controller = this.controllers.get(input.serverId); - if (!controller) { - throw new Error(`Unknown host runtime for serverId ${input.serverId}`); - } - const snapshot = controller.getSnapshot(); - const client = controller.getClient(); - if (!client || snapshot.connectionStatus !== "online") { - throw new Error(`Host ${input.serverId} is not connected`); - } + async refreshAgentDirectory( + input: AgentDirectoryRefreshInput, + ): Promise { + const directory = this.directorySyncByServer.get(input.serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${input.serverId}`); + return directory.refreshAgents(input); + } - controller.markAgentDirectorySyncLoading(); - try { - const session = useSessionStore.getState().sessions[input.serverId]; - if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { - const result = await fetchLegacyDaemonWorkspaceDirectory({ - client, - serverId: input.serverId, - subscribe: input.subscribe, - page: input.page, - }); - controller.markAgentDirectorySyncReady(); - return { - agents: result.agents, - subscriptionId: result.subscriptionId, - }; - } + async refreshWorkspaceDirectory(input: { serverId: string; subscribe?: boolean }): Promise { + const directory = this.directorySyncByServer.get(input.serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${input.serverId}`); + await directory.refreshWorkspaces({ subscribe: input.subscribe }); + } - const directory = await fetchCurrentAgentDirectory({ - client, - filter: input.filter, - subscribe: input.subscribe, - page: input.page, - }); + async refreshDirectories(serverId: string): Promise { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + await directory.refreshAll(); + } - const { agents } = replaceFetchedAgentDirectory({ - serverId: input.serverId, - entries: directory.entries, - }); - - controller.markAgentDirectorySyncReady(); - return { - agents, - subscriptionId: directory.subscriptionId, - }; - } catch (error) { - controller.markAgentDirectorySyncError(toErrorMessage(error)); - throw error; - } + fetchAgentTimeline( + serverId: string, + agentId: string, + request: Parameters[1], + ): Promise>> { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + return directory.fetchTimeline(agentId, request); } refreshAllAgentDirectories(input?: { serverIds?: string[] }): void { 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..3de632990 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 || !supportsDesktopPaneSplits(), + }), + [isFocusModeEnabled, isMobile, isRouteFocused, uiTabs, workspaceLayout], + ); + useEffect(() => { + if (!persistenceKey || !viewedTimelineSync) { + return; + } + viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, visibleAgentIds); + }, [persistenceKey, viewedTimelineSync, visibleAgentIds]); + useEffect(() => { + if (!persistenceKey || !viewedTimelineSync) { + return; + } + return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []); + }, [persistenceKey, viewedTimelineSync]); const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId); const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); const focusedPaneAgentId = useMemo(() => { @@ -2740,7 +2766,7 @@ function WorkspaceScreenContent({ // dropped against the stale cursor. const sessionState = useSessionStore.getState().sessions[normalizedServerId]; const currentCursor = sessionState?.agentTimelineCursor.get(agentId); - await client.fetchAgentTimeline(agentId, { + await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, { direction: "tail", projection: "projected", ...(currentCursor 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/fetch-agent-timeline-once.test.ts b/packages/app/src/timeline/fetch-agent-timeline-once.test.ts new file mode 100644 index 000000000..1ae97e028 --- /dev/null +++ b/packages/app/src/timeline/fetch-agent-timeline-once.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { fetchAgentTimelineOnce } from "./fetch-agent-timeline-once"; + +type TimelinePage = Awaited>; + +test("concurrent identical timeline reads share one request", async () => { + let resolvePage: (page: TimelinePage) => void = () => {}; + const page = new Promise((resolve) => { + resolvePage = resolve; + }); + const requests: Array<{ agentId: string; direction: string }> = []; + const client = { + fetchAgentTimeline: async ( + agentId: string, + request: Parameters[1], + ) => { + requests.push({ agentId, direction: request?.direction ?? "tail" }); + return page; + }, + }; + const request = { direction: "tail" as const, limit: 100, projection: "projected" as const }; + + const first = fetchAgentTimelineOnce(client, "agent", request); + const second = fetchAgentTimelineOnce(client, "agent", request); + resolvePage({ hasNewer: false } as TimelinePage); + + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(requests).toEqual([{ agentId: "agent", direction: "tail" }]); +}); diff --git a/packages/app/src/timeline/fetch-agent-timeline-once.ts b/packages/app/src/timeline/fetch-agent-timeline-once.ts new file mode 100644 index 000000000..e0d67b489 --- /dev/null +++ b/packages/app/src/timeline/fetch-agent-timeline-once.ts @@ -0,0 +1,31 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; + +type TimelineClient = Pick; +type TimelineRequest = Parameters[1]; +type TimelinePage = Awaited>; + +const inFlightByClient = new WeakMap>>(); + +export function fetchAgentTimelineOnce( + client: TimelineClient, + agentId: string, + request: TimelineRequest, +): Promise { + let inFlight = inFlightByClient.get(client); + if (!inFlight) { + inFlight = new Map(); + inFlightByClient.set(client, inFlight); + } + + const key = `${agentId}:${JSON.stringify(request)}`; + const existing = inFlight.get(key); + if (existing) return existing; + + const fetch = client.fetchAgentTimeline(agentId, request); + inFlight.set(key, fetch); + const clear = () => { + if (inFlight.get(key) === fetch) inFlight.delete(key); + }; + void fetch.then(clear, clear); + return fetch; +} 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..511650f62 --- /dev/null +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -0,0 +1,448 @@ +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), + hasAuthoritativeHistory: (agentId) => this.authoritativeHistory.has(agentId), + fetchPage: async (agentId, request) => { + const result = deferred<{ + hasNewer: boolean; + endCursor: { epoch: string; seq: number } | null; + }>(); + this.fetches.push({ + agentId, + request, + respond: ({ hasNewer, seq = 1 }) => + result.resolve({ + hasNewer, + endCursor: { epoch: `epoch-${agentId}`, seq }, + }), + fail: (message) => result.reject(new Error(message)), + }); + this.releaseFetchWaiters(); + return result.promise; + }, + reportError: (error) => { + this.errors.push(error instanceof Error ? error.message : String(error)); + const waiter = this.errorWaiters.shift(); + if (waiter) waiter(this.errors.at(-1) ?? ""); + }, + scheduleRetry: (retry) => { + this.retries.push(retry); + const waiter = this.retryWaiters.shift(); + if (waiter) waiter(this.retries.shift()!); + return () => { + const index = this.retries.indexOf(retry); + if (index >= 0) this.retries.splice(index, 1); + }; + }, + }); + + private readonly memberships: MembershipRequest[] = []; + private readonly membershipWaiters: Array<(request: MembershipRequest) => void> = []; + private readonly fetches: TimelineFetch[] = []; + private readonly fetchWaiters: Array<{ + agentId: string; + resolve(fetch: TimelineFetch): void; + }> = []; + private readonly cursors = new Map(); + private readonly authoritativeHistory = new Set(); + private readonly errorWaiters: Array<(message: string) => void> = []; + private readonly retries: Array<() => void> = []; + private readonly retryWaiters: Array<(retry: () => void) => void> = []; + + setCursor(agentId: string, endSeq: number): void { + this.cursors.set(agentId, { epoch: `epoch-${agentId}`, startSeq: 1, endSeq }); + this.authoritativeHistory.add(agentId); + } + + setLiveCursor(agentId: string, endSeq: number): void { + this.cursors.set(agentId, { epoch: `epoch-${agentId}`, startSeq: 1, endSeq }); + } + + nextMembership(): Promise { + 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("uses a tail fetch when a live cursor is not authoritative", async () => { + const world = new TimelineWorld(); + world.setLiveCursor("agent-a", 9); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + + const fetch = await world.nextFetch("agent-a"); + expect(fetch.request).toEqual({ direction: "tail", limit: 100, projection: "projected" }); + fetch.respond({ hasNewer: false }); +}); + +test("unchanged visible-set publication does not cancel paged catch-up", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const firstPage = await world.nextFetch("agent-a"); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]); + firstPage.respond({ hasNewer: true, seq: 5 }); + const secondPage = await world.nextFetch("agent-a"); + secondPage.respond({ hasNewer: false }); + + expect(secondPage.request).toEqual({ + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 5 }, + limit: 100, + projection: "projected", + }); + world.expectNoPendingMembership(); +}); + +test("all acknowledged agents begin catch-up independently", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-b", "agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + + const [agentA, agentB] = await Promise.all([ + world.nextFetch("agent-a"), + world.nextFetch("agent-b"), + ]); + agentA.respond({ hasNewer: false }); + agentB.respond({ hasNewer: false }); + + expect(membership.agentIds).toEqual(["agent-a", "agent-b"]); +}); + +test("membership changes during acknowledgement never catch up the stale set", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const staleMembership = await world.nextMembership(); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + staleMembership.succeed(); + const currentMembership = await world.nextMembership(); + currentMembership.succeed(); + const agentB = await world.nextFetch("agent-b"); + agentB.respond({ hasNewer: false }); + + expect({ stale: staleMembership.agentIds, current: currentMembership.agentIds }).toEqual({ + stale: ["agent-a"], + current: ["agent-b"], + }); + world.expectNoPendingFetch(); +}); + +test("removing one agent during paging cancels only that agent", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]); + const initialMembership = await world.nextMembership(); + initialMembership.succeed(); + const [agentA, agentB] = await Promise.all([ + world.nextFetch("agent-a"), + world.nextFetch("agent-b"), + ]); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + const replacement = await world.nextMembership(); + agentA.respond({ hasNewer: true, seq: 4 }); + agentB.respond({ hasNewer: true, seq: 7 }); + const agentBNext = await world.nextFetch("agent-b"); + replacement.succeed(); + agentBNext.respond({ hasNewer: false }); + + expect(replacement.agentIds).toEqual(["agent-b"]); + world.expectNoPendingFetch(); +}); + +test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const firstMembership = await world.nextMembership(); + firstMembership.succeed(); + const stalePage = await world.nextFetch("agent-a"); + + world.sync.setConnected(false); + stalePage.respond({ hasNewer: true, seq: 8 }); + world.sync.setConnected(true); + const restoredMembership = await world.nextMembership(); + restoredMembership.succeed(); + const restoredPage = await world.nextFetch("agent-a"); + restoredPage.respond({ hasNewer: false }); + + expect(restoredMembership.agentIds).toEqual(["agent-a"]); + world.expectNoPendingFetch(); +}); + +test("overlapping sources deduplicate membership and source removal preserves remaining views", async () => { + const world = new TimelineWorld(); + world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]); + world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]); + world.sync.setConnected(true); + const combined = await world.nextMembership(); + combined.succeed(); + const [agentA, agentB] = await Promise.all([ + world.nextFetch("agent-a"), + world.nextFetch("agent-b"), + ]); + agentA.respond({ hasNewer: false }); + agentB.respond({ hasNewer: false }); + + world.sync.replaceVisibleAgentIds("left-route", []); + world.expectNoPendingMembership(); + world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]); + const remaining = await world.nextMembership(); + remaining.succeed(); + + expect({ combined: combined.agentIds, remaining: remaining.agentIds }).toEqual({ + combined: ["agent-a", "agent-b"], + remaining: ["agent-b"], + }); +}); + +test("a failed catch-up reports once and retries through the explicit retry policy", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const failed = await world.nextFetch("agent-a"); + failed.fail("timeline unavailable"); + const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]); + + retryCatchUp(); + const retry = await world.nextFetch("agent-a"); + retry.respond({ hasNewer: false }); + + expect({ error, retryDirection: retry.request.direction }).toEqual({ + error: "timeline unavailable", + retryDirection: "tail", + }); + world.expectNoPendingMembership(); +}); + +test("gap recovery supersedes completed catch-up and pages through the current tail", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const initial = await world.nextFetch("agent-a"); + initial.respond({ hasNewer: false }); + + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + const gapPage = await world.nextFetch("agent-a"); + gapPage.respond({ hasNewer: true, seq: 15 }); + const finalPage = await world.nextFetch("agent-a"); + finalPage.respond({ hasNewer: false }); + + expect([gapPage.request, finalPage.request]).toEqual([ + { + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 10 }, + limit: 100, + projection: "projected", + }, + { + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 15 }, + limit: 100, + projection: "projected", + }, + ]); +}); + +test("repeated recovery for the same running gap reuses the in-flight fetch", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const initial = await world.nextFetch("agent-a"); + initial.respond({ hasNewer: false }); + + const cursor = { epoch: "epoch-agent-a", endSeq: 10 }; + world.sync.recoverGap("agent-a", cursor); + const gapPage = await world.nextFetch("agent-a"); + world.sync.recoverGap("agent-a", cursor); + + world.expectNoPendingFetch(); + gapPage.respond({ hasNewer: false }); +}); + +test("membership failure autonomously retries without another visibility declaration", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const failed = await world.nextMembership(); + failed.fail("subscription unavailable"); + const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]); + + retryMembership(); + const retry = await world.nextMembership(); + retry.succeed(); + const catchUp = await world.nextFetch("agent-a"); + catchUp.respond({ hasNewer: false }); + + expect({ error, failed: failed.agentIds, retry: retry.agentIds }).toEqual({ + error: "subscription unavailable", + failed: ["agent-a"], + retry: ["agent-a"], + }); +}); + +test("background sends an empty set and foreground restores visible membership", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const initial = await world.nextMembership(); + initial.succeed(); + const initialCatchUp = await world.nextFetch("agent-a"); + initialCatchUp.respond({ hasNewer: false }); + + world.sync.setActive(false); + const background = await world.nextMembership(); + background.succeed(); + world.sync.setActive(true); + const foreground = await world.nextMembership(); + foreground.succeed(); + const resumedCatchUp = await world.nextFetch("agent-a"); + resumedCatchUp.respond({ hasNewer: false }); + + expect({ background: background.agentIds, foreground: foreground.agentIds }).toEqual({ + background: [], + foreground: ["agent-a"], + }); +}); + +test("stale membership retry cannot overwrite a newer effective set", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const failed = await world.nextMembership(); + failed.fail("subscription unavailable"); + const staleRetry = await world.nextRetry(); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + const current = await world.nextMembership(); + staleRetry(); + current.succeed(); + const catchUp = await world.nextFetch("agent-b"); + catchUp.respond({ hasNewer: false }); + + expect(current.agentIds).toEqual(["agent-b"]); + world.expectNoPendingMembership(); +}); + +test("membership retry cannot run while disconnected", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const failed = await world.nextMembership(); + failed.fail("subscription unavailable"); + const disconnectedRetry = await world.nextRetry(); + + world.sync.setConnected(false); + disconnectedRetry(); + world.expectNoPendingMembership(); + world.sync.setConnected(true); + const restored = await world.nextMembership(); + restored.succeed(); + const catchUp = await world.nextFetch("agent-a"); + catchUp.respond({ hasNewer: false }); + + expect(restored.agentIds).toEqual(["agent-a"]); +}); 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..f0f2c0870 --- /dev/null +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -0,0 +1,321 @@ +import type { AgentTimelineCursorState } from "@/stores/session-store"; +import { + planInitialAgentTimelineSync, + planResumeTimelineSync, + planTimelineCatchUpAfter, + type ProjectedTimelineForwardFetchPlan, +} from "./timeline-sync-plan"; + +interface TimelinePageResult { + hasNewer: boolean; + endCursor: { epoch: string; seq: number } | null; +} + +interface ViewedTimelineSyncPorts { + setSubscription(agentIds: string[]): Promise; + readCursor(agentId: string): AgentTimelineCursorState | undefined; + hasAuthoritativeHistory(agentId: string): boolean; + 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; + request?: ProjectedTimelineForwardFetchPlan; + cancelRetry?: () => void; +} + +function isSameCatchUpRequest( + left: ProjectedTimelineForwardFetchPlan | undefined, + right: ProjectedTimelineForwardFetchPlan | undefined, +): boolean { + if (!left || !right || left.direction !== right.direction) return false; + if (left.direction !== "after" || right.direction !== "after") return true; + return left.cursor.epoch === right.cursor.epoch && left.cursor.seq === right.cursor.seq; +} + +function shouldKeepCurrentCatchUp(input: { + current: CatchUpState | undefined; + request: ProjectedTimelineForwardFetchPlan | undefined; + supersede: boolean; +}): boolean { + if (!input.current) return false; + if (input.supersede) { + return ( + input.current.status === "running" && + isSameCatchUpRequest(input.current.request, input.request) + ); + } + return input.current.status === "running" || input.current.status === "complete"; +} + +function normalizeAgentIds(agentIds: string[]): string[] { + return [...new Set(agentIds)].filter(Boolean).sort(); +} + +function sameAgentIds(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((agentId, index) => agentId === right[index]); +} + +export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): ViewedTimelineSync { + const sources = new Map(); + 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 (shouldKeepCurrentCatchUp({ current, request, supersede })) { + return; + } + current?.cancelRetry?.(); + const generation = (catchUpGenerations.get(agentId) ?? 0) + 1; + catchUpGenerations.set(agentId, generation); + catchUps.set(agentId, { generation, status: "running", request }); + pendingGaps.delete(agentId); + const cursor = ports.readCursor(agentId); + const nextRequest = + request ?? + (ports.hasAuthoritativeHistory(agentId) + ? planResumeTimelineSync({ cursor }) + : planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory: false })); + void fetchUntilCurrent(agentId, generation, nextRequest); + }; + + const startAcknowledgedCatchUps = () => { + for (const agentId of acknowledged) { + const gap = pendingGaps.get(agentId); + startCatchUp(agentId, { request: gap, supersede: Boolean(gap) }); + } + }; + + const reconcileLatestMembership = async (): Promise => { + 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..03387d010 --- /dev/null +++ b/packages/app/src/utils/agent-directory-reconciliation.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import type { Agent } from "@/stores/session-store"; +import { reconcileAgentDirectory } from "./agent-directory-reconciliation"; + +function snapshot(id: string, status: AgentSnapshotPayload["status"]): AgentSnapshotPayload { + return { + id, + provider: "codex", + cwd: "/repo", + model: null, + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:00.000Z", + lastUserMessageAt: null, + status, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }; +} + +function entry(id: string, status: AgentSnapshotPayload["status"]): FetchAgentsEntry { + return { + agent: snapshot(id, status), + project: { + projectKey: "/repo", + projectName: "repo", + checkout: { + cwd: "/repo", + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }; +} + +function replica(id: string, status: Agent["status"]): Agent { + return { + ...snapshot(id, status), + serverId: "server", + createdAt: new Date("2026-07-12T10:00:00.000Z"), + updatedAt: new Date("2026-07-12T10:00:00.000Z"), + lastActivityAt: new Date("2026-07-12T10:00:00.000Z"), + lastUserMessageAt: null, + attentionTimestamp: null, + archivedAt: null, + parentAgentId: null, + }; +} + +describe("agent directory reconciliation", () => { + it("reports snapshot-only and buffered running transitions exactly once", () => { + const result = reconcileAgentDirectory({ + previous: new Map([ + ["snapshot", replica("snapshot", "running")], + ["buffered", replica("buffered", "running")], + ]), + snapshot: [entry("snapshot", "idle"), entry("buffered", "running")], + deltas: [ + { + kind: "upsert", + agent: snapshot("buffered", "idle"), + project: entry("buffered", "idle").project, + }, + { + kind: "upsert", + agent: snapshot("buffered", "idle"), + project: entry("buffered", "idle").project, + }, + ], + }); + + expect(result.stoppedRunningAgentIds).toEqual(["snapshot", "buffered"]); + expect(result.entries.map(({ agent }) => [agent.id, agent.status])).toEqual([ + ["snapshot", "idle"], + ["buffered", "idle"], + ]); + }); + + it("preserves ordered upserts and removals received after page one", () => { + const result = reconcileAgentDirectory({ + previous: new Map(), + snapshot: [entry("updated", "idle"), entry("removed", "idle")], + deltas: [ + { + kind: "upsert", + agent: { ...snapshot("updated", "idle"), title: "live" }, + project: entry("updated", "idle").project, + }, + { kind: "remove", agentId: "removed" }, + ], + }); + + expect(result.entries.map(({ agent }) => [agent.id, agent.title])).toEqual([ + ["updated", "live"], + ]); + }); + + it("keeps newer page metadata when a stale buffered upsert arrives", () => { + const snapshotEntry = entry("agent", "running"); + const staleProject = { + ...snapshotEntry.project, + projectName: "stale project", + }; + const result = reconcileAgentDirectory({ + previous: new Map([["agent", replica("agent", "running")]]), + snapshot: [ + { + ...entry("agent", "running"), + agent: { + ...snapshot("agent", "running"), + title: "newer page", + updatedAt: "2026-07-12T12:00:00.000Z", + }, + }, + ], + deltas: [ + { + kind: "upsert", + agent: { + ...snapshot("agent", "idle"), + title: "stale live", + updatedAt: "2026-07-12T11:00:00.000Z", + }, + project: staleProject, + }, + ], + }); + + expect({ + title: result.entries[0]?.agent.title, + status: result.entries[0]?.agent.status, + projectName: result.entries[0]?.project.projectName, + stopped: result.stoppedRunningAgentIds, + }).toEqual({ title: "newer page", status: "running", projectName: "repo", stopped: [] }); + }); + + it("clears a snapshot stop when a newer buffered upsert is running", () => { + const result = reconcileAgentDirectory({ + previous: new Map([["agent", replica("agent", "running")]]), + snapshot: [entry("agent", "idle")], + deltas: [ + { + kind: "upsert", + agent: { + ...snapshot("agent", "running"), + updatedAt: "2026-07-12T11:00:00.000Z", + }, + project: entry("agent", "running").project, + }, + ], + }); + + expect(result.entries[0]?.agent.status).toBe("running"); + expect(result.stoppedRunningAgentIds).toEqual([]); + }); + + it("accepts usage from a stale buffered upsert without regressing metadata", () => { + const result = reconcileAgentDirectory({ + previous: new Map(), + snapshot: [ + { + ...entry("agent", "idle"), + agent: { + ...snapshot("agent", "idle"), + title: "newer page", + updatedAt: "2026-07-12T12:00:00.000Z", + lastUsage: { inputTokens: 10, outputTokens: 5 }, + }, + }, + ], + deltas: [ + { + kind: "upsert", + agent: { + ...snapshot("agent", "running"), + title: "stale live", + updatedAt: "2026-07-12T11:00:00.000Z", + lastUsage: { inputTokens: 20, outputTokens: 8 }, + }, + project: entry("agent", "idle").project, + }, + ], + }); + + expect({ + title: result.entries[0]?.agent.title, + status: result.entries[0]?.agent.status, + usage: result.entries[0]?.agent.lastUsage, + }).toEqual({ + title: "newer page", + status: "idle", + usage: { inputTokens: 20, outputTokens: 8 }, + }); + }); + + it("preserves usage when a stale buffered upsert omits it", () => { + const result = reconcileAgentDirectory({ + previous: new Map(), + snapshot: [ + { + ...entry("agent", "idle"), + agent: { + ...snapshot("agent", "idle"), + updatedAt: "2026-07-12T12:00:00.000Z", + lastUsage: { inputTokens: 10, outputTokens: 5 }, + }, + }, + ], + deltas: [ + { + kind: "upsert", + agent: { + ...snapshot("agent", "running"), + updatedAt: "2026-07-12T11:00:00.000Z", + }, + project: entry("agent", "idle").project, + }, + ], + }); + + expect(result.entries[0]?.agent.lastUsage).toEqual({ inputTokens: 10, outputTokens: 5 }); + }); +}); 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..86130f5d2 --- /dev/null +++ b/packages/app/src/utils/agent-directory-reconciliation.ts @@ -0,0 +1,62 @@ +import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import type { Agent } from "@/stores/session-store"; +import type { AgentDirectoryDelta } from "./agent-directory-sync"; +import { acceptAgentDirectoryUpdate } from "./agent-directory-update-policy"; + +export function reconcileAgentDirectory(input: { + previous: ReadonlyMap; + 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 (acceptedAgent.status === "running") { + stoppedRunningAgentIds.delete(delta.agent.id); + } else if (statuses.get(delta.agent.id) === "running") { + stoppedRunningAgentIds.add(delta.agent.id); + } + statuses.set(delta.agent.id, acceptedAgent.status); + const previousProject = previousEntry?.project; + const acceptedProject = + acceptedAgent === delta.agent ? (delta.project ?? previousProject) : previousProject; + entries.set(delta.agent.id, { + agent: acceptedAgent, + project: acceptedProject ?? { + projectKey: delta.agent.cwd, + projectName: /[^/]+$/.exec(delta.agent.cwd)?.[0] ?? delta.agent.cwd, + checkout: { + cwd: delta.agent.cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }); + } + + return { + entries: Array.from(entries.values()), + stoppedRunningAgentIds: Array.from(stoppedRunningAgentIds), + }; +} diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index d1c0d1df1..1ec16bde2 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,7 +60,28 @@ function createEntry(agent: AgentSnapshotPayload): FetchAgentsEntry { }; } +function permission(id: string): AgentPermissionRequest { + return { id, provider: "codex", name: id, kind: "tool", title: id }; +} + describe("replaceFetchedAgentDirectory", () => { + it("preserves timeline initialization while replacing directory state", () => { + const serverId = "server-initializing"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + store.setInitializingAgents(serverId, new Map([["agent", true]])); + + replaceFetchedAgentDirectory({ + serverId, + entries: [createEntry(createAgentPayload({ id: "agent" }))], + }); + + expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get("agent")).toBe( + true, + ); + store.clearSession(serverId); + }); + it("re-derives parentAgentId every time an agent snapshot is ingested", () => { const serverId = "server-1"; const store = useSessionStore.getState(); @@ -92,4 +117,142 @@ describe("replaceFetchedAgentDirectory", () => { store.clearSession(serverId); }); + + it("removes every replica-owned artifact for a removed agent", () => { + const serverId = "server-removal"; + const agentId = "removed-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const agent = { + ...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId), + projectPlacement: null, + }; + store.setAgents(serverId, new Map([[agentId, agent]])); + store.setAgentDetails(serverId, new Map([[agentId, agent]])); + store.setQueuedMessages( + serverId, + new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), + ); + store.setAgentTimelineCursor( + serverId, + new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]), + ); + store.setPendingPermissions( + serverId, + new Map([["permission", { key: "permission", agentId, request: null as never }]]), + ); + store.setInitializingAgents(serverId, new Map([[agentId, true]])); + setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); + + applyAgentDirectoryDelta({ serverId, delta: { kind: "remove", agentId } }); + + const session = useSessionStore.getState().sessions[serverId]; + expect({ + agents: session?.agents.has(agentId), + details: session?.agentDetails.has(agentId), + queued: session?.queuedMessages.has(agentId), + cursor: session?.agentTimelineCursor.has(agentId), + permissions: session?.pendingPermissions.size, + initializing: session?.initializingAgents.has(agentId), + archivePending: isAgentArchiving({ queryClient, serverId, agentId }), + }).toEqual({ + agents: false, + details: false, + queued: false, + cursor: false, + permissions: 0, + initializing: false, + archivePending: false, + }); + + store.clearSession(serverId); + }); + + it("keeps newer metadata while accepting usage-only updates and legacy workspace ownership", () => { + const serverId = "server-usage"; + const agentId = "usage-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + store.setWorkspaces( + serverId, + new Map([ + [ + "legacy-workspace", + { + id: "legacy-workspace", + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "repo", + status: "done", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }, + ], + ]), + ); + const current = createAgentPayload({ + id: agentId, + title: "current", + status: "running", + updatedAt: "2026-07-12T11:00:00.000Z", + lastUsage: { inputTokens: 10, outputTokens: 5 }, + pendingPermissions: [permission("current-permission")], + }); + applyAgentDirectoryDelta({ + serverId, + delta: { kind: "upsert", agent: current, project: createEntry(current).project }, + }); + store.flushAgentLastActivity(); + setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); + + const staleResult = applyAgentDirectoryDelta({ + serverId, + delta: { + kind: "upsert", + agent: { + ...current, + title: "stale", + status: "idle", + updatedAt: "2026-07-12T10:00:00.000Z", + lastUsage: { inputTokens: 20, outputTokens: 8 }, + pendingPermissions: [permission("stale-permission")], + archivedAt: "2026-07-12T10:00:00.000Z", + }, + project: createEntry(current).project, + }, + }); + store.flushAgentLastActivity(); + + const state = useSessionStore.getState(); + const agent = state.sessions[serverId]?.agents.get(agentId); + expect({ + title: agent?.title, + status: agent?.status, + usage: agent?.lastUsage, + workspaceId: agent?.workspaceId, + stoppedRunning: staleResult.stoppedRunning, + permissions: Array.from(state.sessions[serverId]?.pendingPermissions.values() ?? []).map( + ({ request }) => request.id, + ), + archivePending: isAgentArchiving({ queryClient, serverId, agentId }), + activity: state.agentLastActivity.get(agentId)?.toISOString(), + }).toEqual({ + title: "current", + status: "running", + usage: { inputTokens: 20, outputTokens: 8 }, + workspaceId: "legacy-workspace", + stoppedRunning: false, + permissions: ["current-permission"], + archivePending: true, + activity: "2026-07-12T11:00:00.000Z", + }); + + store.clearSession(serverId); + }); }); diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index 684aaf82b..24e14c420 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -2,8 +2,132 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import { type Agent, useSessionStore } from "@/stores/session-store"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; +import { queryClient } from "@/data/query-client"; +import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy"; +import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { useDraftStore } from "@/stores/draft-store"; +import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization"; type AgentDirectoryFetchEntry = FetchAgentsEntry; +export type AgentDirectoryDelta = Extract< + SessionOutboundMessage, + { type: "agent_update" } +>["payload"]; + +export function applyAgentDirectoryDelta(input: { serverId: string; delta: AgentDirectoryDelta }): { + agentId: string; + stoppedRunning: boolean; +} { + if (input.delta.kind === "remove") { + removeAgentDirectoryReplica(input.serverId, input.delta.agentId); + return { agentId: input.delta.agentId, stoppedRunning: false }; + } + return upsertAgentDirectoryReplica(input.serverId, input.delta); +} + +type AgentUpsertDelta = Extract; + +function upsertAgentDirectoryReplica( + serverId: string, + delta: AgentUpsertDelta, +): { agentId: string; stoppedRunning: boolean } { + const normalized = normalizeAgentSnapshot(delta.agent, serverId); + const session = useSessionStore.getState().sessions[serverId]; + const previousAgent = + session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id); + const legacyWorkspaceId = + previousAgent?.workspaceId ?? + Array.from(session?.workspaces.values() ?? []).find( + (workspace) => + session?.serverInfo?.features?.workspaceMultiplicity !== true && + workspace.workspaceDirectory === normalized.cwd, + )?.id; + const agent: Agent = { + ...normalized, + workspaceId: normalized.workspaceId ?? legacyWorkspaceId, + projectPlacement: + resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ?? + previousAgent?.projectPlacement, + }; + const acceptedAgent = upsertAgentReplica(serverId, agent); + if (acceptedAgent.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId, agentId: acceptedAgent.id }); + } + replaceAgentPendingPermissions(serverId, acceptedAgent); + useSessionStore.getState().setAgentLastActivity(acceptedAgent.id, acceptedAgent.lastActivityAt); + return { + agentId: acceptedAgent.id, + stoppedRunning: previousAgent?.status === "running" && acceptedAgent.status !== "running", + }; +} + +export function upsertAgentReplica(serverId: string, agent: Agent): Agent { + let acceptedAgent = agent; + useSessionStore.getState().setAgents(serverId, (current) => { + const currentAgent = current.get(agent.id); + acceptedAgent = acceptAgentDirectoryUpdate(currentAgent, agent); + if (acceptedAgent === currentAgent) return current; + const next = new Map(current); + next.set(agent.id, acceptedAgent); + return next; + }); + return acceptedAgent; +} + +export function replaceAgentPendingPermissions(serverId: string, agent: Agent): void { + const pendingPermissions = new Map( + useSessionStore.getState().sessions[serverId]?.pendingPermissions, + ); + for (const [key, pending] of pendingPermissions) { + if (pending.agentId === agent.id) pendingPermissions.delete(key); + } + for (const request of agent.pendingPermissions) { + const key = derivePendingPermissionKey(agent.id, request); + pendingPermissions.set(key, { key, agentId: agent.id, request }); + } + useSessionStore.getState().setPendingPermissions(serverId, pendingPermissions); +} + +export function removeAgentDirectoryReplica(serverId: string, agentId: string): void { + const store = useSessionStore.getState(); + clearArchiveAgentPending({ queryClient, serverId, agentId }); + const removeKey = (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.setInitializingAgents(serverId, removeKey); + store.setPendingPermissions(serverId, (current) => { + const next = new Map(current); + for (const [key, pending] of next) { + if (pending.agentId === agentId) next.delete(key); + } + return next.size === current.size ? current : next; + }); + store.setAgentAuthoritativeHistoryApplied(serverId, agentId, false); + store.setAgentStreamTail(serverId, removeKey); + store.clearAgentStreamHead(serverId, agentId); + useSessionStore.setState((state) => { + if (!state.agentLastActivity.has(agentId)) return state; + const agentLastActivity = new Map(state.agentLastActivity); + agentLastActivity.delete(agentId); + return { ...state, agentLastActivity }; + }); + useDraftStore.getState().clearDraftInput({ + draftKey: buildDraftStoreKey({ serverId, agentId }), + }); + const initKey = getInitKey(serverId, agentId); + if (getInitDeferred(initKey)) { + rejectInitDeferred(initKey, new Error("Agent was removed during initialization")); + } +} interface PendingPermissionEntry { key: string; @@ -49,6 +173,12 @@ export function replaceFetchedAgentDirectory(input: { const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input); const store = useSessionStore.getState(); + for (const agent of fetchedAgents.values()) { + if (agent.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: input.serverId, agentId: agent.id }); + } + } + store.setAgents(input.serverId, fetchedAgents); store.setAgentDetails(input.serverId, (prev) => { let next: Map | null = null; @@ -69,7 +199,6 @@ export function replaceFetchedAgentDirectory(input: { store.setAgentLastActivityBatch(lastActivityByAgentId); store.setPendingPermissions(input.serverId, new Map(pendingPermissions)); - store.setInitializingAgents(input.serverId, new Map()); store.setHasHydratedAgents(input.serverId, true); return { agents: fetchedAgents }; } 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..4d14f101b --- /dev/null +++ b/packages/app/src/utils/agent-directory-update-policy.ts @@ -0,0 +1,21 @@ +import equal from "fast-deep-equal"; +import type { AgentUsage } from "@getpaseo/protocol/agent-types"; + +interface AgentUpdateValue { + updatedAt: Date | string; + lastUsage?: AgentUsage; +} + +function timestamp(value: Date | string): number { + return value instanceof Date ? value.getTime() : Date.parse(value); +} + +export function acceptAgentDirectoryUpdate( + current: T | undefined, + incoming: T, +): T { + if (!current || timestamp(incoming.updatedAt) >= timestamp(current.updatedAt)) return incoming; + if (incoming.lastUsage === undefined) return current; + if (equal(incoming.lastUsage, current.lastUsage)) return current; + return { ...current, lastUsage: incoming.lastUsage }; +} diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts index 23de643b3..820e37368 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 { @@ -255,7 +255,9 @@ function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[ }); } -function buildLegacyWorkspaces(entries: FetchAgentsEntry[]): Map { +export function buildLegacyWorkspaces( + entries: FetchAgentsEntry[], +): Map { const workspaces = new Map(); for (const entry of entries) { const workspaceId = entry.agent.workspaceId ?? resolveLegacyWorkspaceId(entry); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 3b3183862..ad41bddda 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 } : {}), }, }, }), @@ -185,6 +186,7 @@ test("does not infer browser automation capabilities from Electron runtime", asy }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined(); + expect(hello.capabilities[CLIENT_CAPS.selectiveAgentTimeline]).toBeUndefined(); }); test("advertises consumer-provided browser automation capabilities", async () => { @@ -219,6 +221,95 @@ test("advertises consumer-provided browser automation capabilities", async () => }); }); +test("sets the complete viewed timeline subscription only when the daemon supports it", async () => { + const supportedTransport = createMockTransport(); + const supportedClient = new DaemonClient({ + url: "ws://test", + clientId: "timeline_supported", + transportFactory: () => supportedTransport.transport, + reconnect: { enabled: false }, + }); + const legacyTransport = createMockTransport(); + const legacyClient = new DaemonClient({ + url: "ws://test", + clientId: "timeline_legacy", + transportFactory: () => legacyTransport.transport, + reconnect: { enabled: false }, + }); + clients.push(supportedClient, legacyClient); + + const supportedConnect = supportedClient.connect(); + supportedTransport.triggerOpen({ features: { selectiveAgentTimeline: true } }); + await supportedConnect; + const legacyConnect = legacyClient.connect(); + legacyTransport.triggerOpen(); + await legacyConnect; + + expect(supportedClient.getLastServerInfoMessage()?.features).toEqual({ + selectiveAgentTimeline: true, + }); + + const setPromise = supportedClient.setAgentTimelineSubscription(["agent-b", "agent-a"]); + await Promise.resolve(); + const request = parseSentFrame(supportedTransport.sent[0]); + supportedTransport.triggerMessage( + wrapSessionMessage({ + type: "agent.timeline.set_subscription.response", + payload: { + requestId: request.requestId, + agentIds: ["agent-a", "agent-b"], + }, + }), + ); + await setPromise; + await legacyClient.setAgentTimelineSubscription(["agent-a"]); + + expect({ request, legacyFrames: legacyTransport.sent }).toEqual({ + request: { + type: "agent.timeline.set_subscription.request", + requestId: expect.any(String), + agentIds: ["agent-a", "agent-b"], + }, + legacyFrames: [], + }); +}); + +test("normalizes legacy and dedicated agent attention notifications", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "attention_normalization", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connect = client.connect(); + mock.triggerOpen(); + await connect; + const notifications: unknown[] = []; + client.onAgentAttentionRequired((notification) => notifications.push(notification)); + const payload = { + agentId: "agent-a", + reason: "finished", + timestamp: "2026-07-12T00:00:00.000Z", + shouldNotify: true, + } as const; + + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_stream", + payload: { + agentId: payload.agentId, + timestamp: payload.timestamp, + event: { type: "attention_required", provider: "codex", ...payload }, + }, + }), + ); + mock.triggerMessage(wrapSessionMessage({ type: "agent_attention_required", payload })); + + expect(notifications).toEqual([payload, payload]); +}); + const noopLogger: Logger = { debug: () => {}, info: () => {}, diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 33a801b35..91bd350a3 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 = {}, 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/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 2cccfcbee..829422ea7 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -3033,11 +3033,11 @@ describe("OpenCode provider subagent contract", () => { event: { type: "timeline", id: "ses_child_with_history", - item: { + item: expect.objectContaining({ type: "assistant_message", text: "Persisted child result.", messageId: "msg_child_history", - }, + }), timestamp: "1970-01-01T00:00:02.000Z", }, }), @@ -3111,11 +3111,11 @@ describe("OpenCode provider subagent contract", () => { event: expect.objectContaining({ type: "timeline", id: "ses_child_hydrating", - item: { + item: expect.objectContaining({ type: "assistant_message", text: "Hydration did not lose this.", messageId: "msg_child_hydrating", - }, + }), }), }), ), 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..8933def32 --- /dev/null +++ b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, expect, test } from "vitest"; +import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { DaemonClient } from "./test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js"; + +interface MessageWaiter { + predicate(message: SessionOutboundMessage): boolean; + resolve(message: SessionOutboundMessage): void; + reject(error: Error): void; + timeout: ReturnType; +} + +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("subscription acknowledgements stay on the requesting socket of a retained session", async () => { + const legacy = await connect({ clientId: "shared-client", selective: false }); + const capable = await connect({ clientId: "shared-client", selective: true }); + legacy.clear(); + capable.clear(); + + await capable.client.setAgentTimelineSubscription(["agent-a"]); + await capable.barrier("targeted-subscription-ack"); + + expect( + legacy.messages.some((message) => message.type === "agent.timeline.set_subscription.response"), + ).toBe(false); +}); + +test("real WebSocket sessions enforce selective delivery, retained resets, downgrade, and dedicated attention", async () => { + const legacy = await connect({ clientId: "legacy-client", selective: false }); + let capable = await connect({ clientId: "capable-client", selective: true }); + const agents = await Promise.all( + ["A", "B", "C"].map((title) => + legacy.client.createAgent({ + provider: "codex", + cwd: "/tmp", + title: `Selective ${title}`, + modeId: "full-access", + }), + ), + ); + const [agentA, agentB, agentC] = agents; + legacy.clear(); + capable.clear(); + + await daemon.daemon.agentManager.emitLiveTimelineItem(agentC.id, { + type: "assistant_message", + text: "before membership", + }); + await legacy.next(isAgentStream(agentC.id), "legacy global delivery before membership"); + await capable.barrier("before-membership"); + expect(capable.hasTimeline(agentC.id)).toBe(false); + + await capable.client.setAgentTimelineSubscription([agentA.id, agentB.id]); + legacy.clear(); + capable.clear(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { + type: "assistant_message", + text: "viewed A", + }); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, { + type: "assistant_message", + text: "viewed B", + }); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentC.id, { + type: "assistant_message", + text: "unviewed C", + }); + await Promise.all([ + capable.next(isAgentStream(agentA.id), "capable A delivery"), + capable.next(isAgentStream(agentB.id), "capable B delivery"), + legacy.next(isAgentStream(agentC.id), "legacy C delivery"), + ]); + await capable.barrier("unviewed-c"); + expect(capable.hasTimeline(agentC.id)).toBe(false); + + await capable.client.setAgentTimelineSubscription([agentB.id]); + legacy.clear(); + capable.clear(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { + type: "assistant_message", + text: "removed A", + }); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, { + type: "assistant_message", + text: "retained B", + }); + await Promise.all([ + legacy.next(isAgentStream(agentA.id), "legacy removed A delivery"), + capable.next(isAgentStream(agentB.id), "capable retained B delivery"), + ]); + await capable.barrier("removed-a"); + expect(capable.hasTimeline(agentA.id)).toBe(false); + + capable.close(); + await capable.client.close(); + clients.splice(clients.indexOf(capable), 1); + capable = await connect({ clientId: "capable-client", selective: true }); + legacy.clear(); + capable.clear(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, { + type: "assistant_message", + text: "after capable resume", + }); + await legacy.next(isAgentStream(agentB.id), "legacy delivery after capable resume"); + await capable.barrier("resumed-membership-reset"); + expect(capable.hasTimeline(agentB.id)).toBe(false); + + capable.client.sendHeartbeat({ + deviceType: "mobile", + focusedAgentId: null, + lastActivityAt: new Date().toISOString(), + appVisible: true, + }); + legacy.clear(); + capable.clear(); + const attention = capable.next( + isDedicatedAttention(agentC.id), + "capable dedicated attention notification", + ); + const legacyAttention = legacy.next( + isLegacyAttention(agentC.id), + "legacy attention stream notification", + ); + await legacy.client.sendMessage(agentC.id, "finish attention boundary test"); + const [attentionMessage, legacyAttentionMessage] = await Promise.all([ + attention, + legacyAttention, + ]); + await capable.barrier("attention-delivery"); + expect({ + capable: dedicatedAttentionResult(attentionMessage, capable.hasTimeline(agentC.id)), + legacy: legacyAttentionResult(legacyAttentionMessage), + }).toEqual({ + capable: { + type: "agent_attention_required", + shouldNotify: true, + timelineLeaked: false, + }, + legacy: { + type: "agent_stream", + eventType: "attention_required", + agentId: agentC.id, + }, + }); + + capable.close(); + await capable.client.close(); + clients.splice(clients.indexOf(capable), 1); + const downgraded = await connect({ clientId: "capable-client", selective: false }); + downgraded.clear(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { + type: "assistant_message", + text: "after downgrade", + }); + const downgradedDelivery = await downgraded.next( + isAgentStream(agentA.id), + "legacy global delivery after capability downgrade", + ); + + expect(downgradedDelivery.type).toBe("agent_stream"); +}, 30_000); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index c0fcff4bb..9f05447e8 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -10,6 +10,7 @@ import { assertPullRequestAutoMergeEnableReady, } from "../services/github-service.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { decodeFileTransferFrame, @@ -21,6 +22,7 @@ import { Session } from "./session.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; +import type { AgentManagerEvent } from "./agent/agent-manager.js"; import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { SessionOptions } from "./session.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js"; @@ -309,6 +311,7 @@ interface SessionForTestOptions { daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"]; downloadTokenStore?: SessionOptions["downloadTokenStore"]; messages?: unknown[]; + targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>; binaryMessages?: Uint8Array[]; } @@ -346,6 +349,12 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { return new Session({ clientId: "test-client", onMessage: (message) => messages.push(message), + ...(options.targetedMessages + ? { + onMessageToSource: (source: object, message: SessionOutboundMessage) => + options.targetedMessages?.push({ source, message }), + } + : {}), onBinaryMessage: createBinaryMessageHandler(options.binaryMessages), logger, downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(), @@ -4554,6 +4563,216 @@ describe("chat/schedule/loop dispatch routing (behavior preservation)", () => { }); }); +test("replaces a capable session's complete viewed timeline set", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages }); + session.updateClientCapabilities({ selective_agent_timeline: true }); + + await session.handleMessage({ + type: "agent.timeline.set_subscription.request", + agentIds: ["agent-b", "agent-a", "agent-a"], + requestId: "timeline-subscription-1", + }); + + expect(messages).toEqual([ + { + type: "agent.timeline.set_subscription.response", + payload: { + agentIds: ["agent-a", "agent-b"], + requestId: "timeline-subscription-1", + }, + }, + ]); +}); + +test("acknowledges a timeline subscription only to its socket source", async () => { + const messages: SessionOutboundMessage[] = []; + const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = []; + const session = createSessionForTest({ messages, targetedMessages }); + const capableSocket = {}; + session.updateClientCapabilities({ selective_agent_timeline: true }, capableSocket); + + await session.handleMessage( + { + type: "agent.timeline.set_subscription.request", + agentIds: ["agent-a"], + requestId: "timeline-subscription-targeted", + }, + capableSocket, + ); + + expect(messages).toEqual([]); + expect(targetedMessages).toEqual([ + { + source: capableSocket, + message: { + type: "agent.timeline.set_subscription.response", + payload: { + agentIds: ["agent-a"], + requestId: "timeline-subscription-targeted", + }, + }, + }, + ]); +}); + +test("unions viewed timelines across socket sources and removes detached sources", async () => { + const messages: SessionOutboundMessage[] = []; + const agentEventListeners: Array<(event: AgentManagerEvent) => void> = []; + const session = createSessionForTest({ + messages, + agentManager: { + subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { + agentEventListeners.push(listener); + return () => {}; + }), + }, + }); + session.updateClientCapabilities({ selective_agent_timeline: true }); + const firstSocket = {}; + const secondSocket = {}; + session.updateClientCapabilities({ selective_agent_timeline: true }, firstSocket); + session.updateClientCapabilities({ selective_agent_timeline: true }, secondSocket); + + await session.handleMessage( + { + type: "agent.timeline.set_subscription.request", + agentIds: ["agent-a"], + requestId: "timeline-subscription-a", + }, + firstSocket, + ); + await session.handleMessage( + { + type: "agent.timeline.set_subscription.request", + agentIds: ["agent-b"], + requestId: "timeline-subscription-b", + }, + secondSocket, + ); + messages.length = 0; + + if (agentEventListeners.length === 0) throw new Error("Agent event listener was not installed"); + const forward = (event: AgentManagerEvent) => { + for (const listener of agentEventListeners) listener(event); + }; + forward({ + type: "agent_stream", + agentId: "agent-a", + event: { + type: "timeline", + provider: "mock", + item: { type: "assistant_message", messageId: "message-a", text: "A" }, + }, + }); + forward({ + type: "agent_stream", + agentId: "agent-b", + event: { + type: "timeline", + provider: "mock", + item: { type: "assistant_message", messageId: "message-b", text: "B" }, + }, + }); + expect(messages.filter((message) => message.type === "agent_stream")).toHaveLength(2); + + const legacySocket = {}; + session.updateClientCapabilities(null, legacySocket); + expect(session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, legacySocket)).toBe(false); + expect(session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, firstSocket)).toBe(true); + messages.length = 0; + forward({ + type: "agent_stream", + agentId: "agent-not-viewed", + event: { + type: "timeline", + provider: "mock", + item: { type: "assistant_message", messageId: "message-legacy", text: "legacy" }, + }, + }); + expect(messages.some((message) => message.type === "agent_stream")).toBe(true); + + session.clearAgentTimelineSubscription(legacySocket); + + session.clearAgentTimelineSubscription(firstSocket); + messages.length = 0; + forward({ + type: "agent_stream", + agentId: "agent-a", + event: { + type: "timeline", + provider: "mock", + item: { type: "assistant_message", messageId: "message-a-2", text: "detached A" }, + }, + }); + forward({ + type: "agent_stream", + agentId: "agent-b", + event: { + type: "timeline", + provider: "mock", + item: { type: "assistant_message", messageId: "message-b-2", text: "retained B" }, + }, + }); + expect( + messages.flatMap((message) => + message.type === "agent_stream" ? [message.payload.agentId] : [], + ), + ).toEqual(["agent-b"]); +}); + +test("keeps selective delivery scoped per socket when a retained session also has a legacy socket", async () => { + const messages: SessionOutboundMessage[] = []; + const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = []; + const agentEventListeners: Array<(event: AgentManagerEvent) => void> = []; + const session = createSessionForTest({ + messages, + targetedMessages, + agentManager: { + subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { + agentEventListeners.push(listener); + return () => {}; + }), + }, + }); + const legacySocket = {}; + const selectiveSocket = {}; + session.updateClientCapabilities(null, legacySocket); + session.updateClientCapabilities({ selective_agent_timeline: true }, selectiveSocket); + await session.handleMessage( + { + type: "agent.timeline.set_subscription.request", + agentIds: ["viewed-agent"], + requestId: "timeline-subscription-selective", + }, + selectiveSocket, + ); + targetedMessages.length = 0; + + const listener = agentEventListeners[0]; + if (!listener) throw new Error("Agent event listener was not installed"); + listener({ + type: "agent_stream", + agentId: "not-viewed-agent", + event: { + type: "timeline", + provider: "mock", + item: { type: "assistant_message", messageId: "message-global", text: "global" }, + }, + }); + + expect(messages).toEqual([]); + expect(targetedMessages).toEqual([ + { + source: legacySocket, + message: expect.objectContaining({ + type: "agent_stream", + payload: expect.objectContaining({ agentId: "not-viewed-agent" }), + }), + }, + ]); +}); + describe("agent config setters", () => { test("set_agent_mode_request: success emits accepted response carrying the notice", async () => { const messages: SessionOutboundMessage[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2416c77e3..ee010eb29 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -429,6 +429,7 @@ export interface SessionOptions { appVersion?: string | null; clientCapabilities?: Record | null; onMessage: (msg: SessionOutboundMessage) => void; + onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void; onBinaryMessage?: (frame: Uint8Array) => void; getTransportBufferedAmount?: () => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; @@ -558,6 +559,9 @@ export class Session { private clientCapabilities: ReadonlySet; private readonly sessionId: string; private readonly onMessage: (msg: SessionOutboundMessage) => void; + private readonly onMessageToSource: + | ((source: object, msg: SessionOutboundMessage) => void) + | null; private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null; private readonly getTransportBufferedAmount: () => number | null; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; @@ -583,6 +587,10 @@ export class Session { private readonly daemonConfigStore: DaemonConfigStore; private readonly pushTokenStore: PushTokenStore; private unsubscribeAgentEvents: (() => void) | null = null; + private viewedTimelineAgentIds = new Set(); + private readonly viewedTimelineAgentIdsBySource = new Map>(); + private readonly selectiveTimelineCapabilityBySource = new Map(); + private readonly defaultTimelineSubscriptionSource = {}; private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; private readonly agentUpdates: AgentUpdatesService; private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null; @@ -625,6 +633,7 @@ export class Session { appVersion, clientCapabilities, onMessage, + onMessageToSource, onBinaryMessage, getTransportBufferedAmount, onLifecycleIntent, @@ -675,6 +684,7 @@ export class Session { this.clientCapabilities = parseClientCapabilities(clientCapabilities); this.sessionId = uuidv4(); this.onMessage = onMessage; + this.onMessageToSource = onMessageToSource ?? null; this.onBinaryMessage = onBinaryMessage ?? null; this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0); this.onLifecycleIntent = onLifecycleIntent ?? null; @@ -963,14 +973,118 @@ export class Session { } } - updateClientCapabilities(capabilities: Record | null): void { + updateClientCapabilities(capabilities: Record | null, source?: object): void { this.clientCapabilities = parseClientCapabilities(capabilities); + if (source) { + this.selectiveTimelineCapabilityBySource.set( + source, + this.supports(CLIENT_CAPS.selectiveAgentTimeline), + ); + } + if (!source && !this.supports(CLIENT_CAPS.selectiveAgentTimeline)) { + this.viewedTimelineAgentIdsBySource.clear(); + this.viewedTimelineAgentIds.clear(); + } + } + + clearAgentTimelineSubscription(source: object): void { + this.selectiveTimelineCapabilityBySource.delete(source); + if (this.viewedTimelineAgentIdsBySource.delete(source)) { + this.rebuildViewedTimelineAgentIds(); + } + } + + private replaceAgentTimelineSubscription(source: object | undefined, agentIds: string[]): void { + const subscriptionSource = source ?? this.defaultTimelineSubscriptionSource; + if (agentIds.length === 0) this.viewedTimelineAgentIdsBySource.delete(subscriptionSource); + else this.viewedTimelineAgentIdsBySource.set(subscriptionSource, new Set(agentIds)); + this.rebuildViewedTimelineAgentIds(); + } + + private rebuildViewedTimelineAgentIds(): void { + const viewedAgentIds = new Set(); + for (const agentIds of this.viewedTimelineAgentIdsBySource.values()) { + for (const agentId of agentIds) viewedAgentIds.add(agentId); + } + this.viewedTimelineAgentIds = viewedAgentIds; + } + + private usesSelectiveTimelineDelivery(): boolean { + if (this.selectiveTimelineCapabilityBySource.size === 0) { + return this.supports(CLIENT_CAPS.selectiveAgentTimeline); + } + for (const capable of this.selectiveTimelineCapabilityBySource.values()) { + if (!capable) return false; + } + return true; + } + + private forwardAgentStream( + event: Extract, + serializedEvent: Extract["payload"]["event"], + ): void { + if (this.selectiveTimelineCapabilityBySource.size === 0 || !this.onMessageToSource) { + if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") { + this.emit({ + type: "agent_attention_required", + payload: { + agentId: event.agentId, + reason: serializedEvent.reason, + timestamp: serializedEvent.timestamp, + shouldNotify: serializedEvent.shouldNotify, + ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), + }, + }); + } else if ( + !this.usesSelectiveTimelineDelivery() || + this.viewedTimelineAgentIds.has(event.agentId) + ) { + this.emit({ + type: "agent_stream", + payload: this.buildAgentStreamPayload(event, serializedEvent), + }); + } + return; + } + + for (const [source, supportsSelectiveDelivery] of this.selectiveTimelineCapabilityBySource) { + if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") { + this.onMessageToSource(source, { + type: "agent_attention_required", + payload: { + agentId: event.agentId, + reason: serializedEvent.reason, + timestamp: serializedEvent.timestamp, + shouldNotify: serializedEvent.shouldNotify, + ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), + }, + }); + continue; + } + if ( + supportsSelectiveDelivery && + !this.viewedTimelineAgentIdsBySource.get(source)?.has(event.agentId) + ) { + continue; + } + this.onMessageToSource(source, { + type: "agent_stream", + payload: this.buildAgentStreamPayload(event, serializedEvent), + }); + } } supports(capability: ClientCapability): boolean { return this.clientCapabilities.has(capability); } + supportsForSource(capability: ClientCapability, source: object): boolean { + if (capability === CLIENT_CAPS.selectiveAgentTimeline) { + return this.selectiveTimelineCapabilityBySource.get(source) ?? this.supports(capability); + } + return this.supports(capability); + } + async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise { await this.workspaceGitObserver.syncObserverForWorkspace(workspace); } @@ -979,6 +1093,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 +1395,7 @@ export class Session { "agent.session.forward_stream", ); - this.emit({ - type: "agent_stream", - payload: this.buildAgentStreamPayload(event, serializedEvent), - }); + this.forwardAgentStream(event, serializedEvent); if (event.event.type === "permission_requested") { this.emit({ @@ -1377,7 +1498,7 @@ export class Session { /** * Main entry point for processing session messages */ - public async handleMessage(msg: SessionInboundMessage): Promise { + public async handleMessage(msg: SessionInboundMessage, source?: object): Promise { this.inflightRequests++; if (this.inflightRequests > this.peakInflightRequests) { this.peakInflightRequests = this.inflightRequests; @@ -1391,7 +1512,7 @@ export class Session { "agent.session.inbound", ); try { - await this.dispatchInboundMessage(msg); + await this.dispatchInboundMessage(msg, source); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.sessionLogger.error({ err }, "Error handling message"); @@ -1429,12 +1550,12 @@ export class Session { } } - private async dispatchInboundMessage(msg: SessionInboundMessage): Promise { + private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise { const promise = this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchAgentRewindMessage(msg) ?? this.dispatchAgentRelationshipMessage(msg) ?? - this.dispatchAgentTimelineMessage(msg) ?? + this.dispatchAgentTimelineMessage(msg, source) ?? this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? @@ -1516,7 +1637,10 @@ export class Session { } } - private dispatchAgentTimelineMessage(msg: SessionInboundMessage): Promise | undefined { + private dispatchAgentTimelineMessage( + msg: SessionInboundMessage, + source?: object, + ): Promise | undefined { switch (msg.type) { case "fetch_agent_timeline_request": return this.handleFetchAgentTimelineRequest(msg); @@ -1524,6 +1648,24 @@ export class Session { return this.handleProviderSubagentListRequest(msg); case "agent.provider_subagents.timeline.get.request": return this.handleProviderSubagentTimelineRequest(msg); + case "agent.timeline.set_subscription.request": { + const agentIds = [...new Set(msg.agentIds)].sort(); + if ( + source + ? (this.selectiveTimelineCapabilityBySource.get(source) ?? + this.supports(CLIENT_CAPS.selectiveAgentTimeline)) + : this.supports(CLIENT_CAPS.selectiveAgentTimeline) + ) { + this.replaceAgentTimelineSubscription(source, agentIds); + } + const response: SessionOutboundMessage = { + type: "agent.timeline.set_subscription.response", + payload: { agentIds, requestId: msg.requestId }, + }; + if (source && this.onMessageToSource) this.onMessageToSource(source, response); + else this.emit(response); + return undefined; + } case "agent.fork_context.request": return this.handleAgentForkContextRequest(msg); default: @@ -3967,6 +4109,8 @@ export class Session { continue; } } + const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id; + subscription.lastEmittedByWorkspaceId.set(workspaceId, payload); this.emit({ type: "workspace_update", payload, @@ -4259,6 +4403,9 @@ export class Session { this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace); if (!nextWorkspace) { + if (workspace && !subscription.lastEmittedByWorkspaceId.has(workspaceId)) { + continue; + } subscription.lastEmittedByWorkspaceId.delete(workspaceId); this.bufferOrEmitWorkspaceUpdate( subscription, @@ -4515,6 +4662,7 @@ export class Session { "fetch_workspaces_response_ready", ); const snapshot = this.buildBootstrapSnapshot(payload.entries); + this.seedWorkspaceSubscriptionSnapshot(subscriptionId, request.filter, payload.entries); this.emit({ type: "fetch_workspaces_response", @@ -4575,6 +4723,23 @@ export class Session { return { snapshotByWorkspaceId }; } + private seedWorkspaceSubscriptionSnapshot( + subscriptionId: string | null, + filter: FetchWorkspacesRequestFilter | undefined, + entries: FetchWorkspacesResponseEntry[], + ): void { + const subscription = this.workspaceUpdatesSubscription; + if (!subscription) return; + if (subscriptionId && subscription.subscriptionId !== subscriptionId) return; + if (!subscriptionId && !equal(subscription.filter, filter)) return; + for (const entry of entries) { + subscription.lastEmittedByWorkspaceId.set(entry.id, { + kind: "upsert", + workspace: entry, + }); + } + } + private async registerWorkspaceForImportedAgent( workspace: PersistedWorkspaceRecord, ): Promise { @@ -4661,7 +4826,7 @@ export class Session { error: null, }, }); - await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); + await this.emitCreatedWorkspaceUpdate(descriptor); void this.workspaceGitService .getSnapshot(workspace.cwd, { force: true, includeForge: true, reason: "open_project" }) .catch((error) => { @@ -4746,10 +4911,7 @@ export class Session { error: null, }, }); - this.emit({ - type: "workspace_update", - payload: { kind: "upsert", workspace: descriptor }, - }); + await this.emitCreatedWorkspaceUpdate(descriptor); } private async resolveWorktreeSourceCwd(input: { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 1b23c3bbf..99ae1169f 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -6568,6 +6568,64 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho ); }); +test("a workspace leaving a filtered subscription after bootstrap emits a removal", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = asTestSession( + createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }), + ); + const descriptor = { + id: "ws-buffered", + projectId: "proj-buffered", + projectDisplayName: "repo", + projectRootPath: REPO_CWD, + workspaceDirectory: REPO_CWD, + projectKind: "git" as const, + workspaceKind: "local_checkout" as const, + name: "repo work", + status: "done" as const, + activityAt: null, + diffStat: null, + }; + let currentDescriptor: typeof descriptor | null = descriptor; + let finishListing: (result: ListFetchResult) => void = () => {}; + const listing = new Promise((resolve) => { + finishListing = resolve; + }); + session.listFetchWorkspacesEntries = async () => listing; + session.buildWorkspaceDescriptorMap = async () => + new Map(currentDescriptor ? [[currentDescriptor.id, currentDescriptor]] : []); + session.reconcileAndEmitWorkspaceUpdates = async () => undefined; + + const bootstrap = session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-buffered-filter", + filter: { query: "repo" }, + subscribe: { subscriptionId: "sub-buffered-filter" }, + }); + await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id], { skipReconcile: true }); + finishListing({ + entries: [], + emptyProjects: [], + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + }); + await bootstrap; + + expect(filterByType(emitted, "workspace_update")).toEqual([ + { type: "workspace_update", payload: { kind: "upsert", workspace: descriptor } }, + ]); + + emitted.length = 0; + currentDescriptor = { ...descriptor, name: "other work" }; + await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id], { skipReconcile: true }); + + expect(filterByType(emitted, "workspace_update")).toEqual([ + { + type: "workspace_update", + payload: { kind: "remove", id: descriptor.id }, + }, + ]); +}); + test("project.rename.request stores customName and emits an updated workspace descriptor", async () => { const emitted: SessionOutboundMessage[] = []; const session = asTestSession( @@ -7827,4 +7885,75 @@ test("workspace.create.response persists the first prompt as the initial title", expect(workspaceId).toBeDefined(); const persisted = await session.workspaceRegistry.get(workspaceId as string); expect(persisted?.title).toBe("Add retries to the payments flow"); + expect(filterByType(emitted, "workspace_update")).toHaveLength(1); +}); + +test("workspace create emits through a matching workspace subscription", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspaces = new Map>(); + 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..0772e7bd5 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -173,6 +173,8 @@ function createSessionWithActivity( ) { return { getClientActivity: vi.fn(() => activity), + supports: () => false, + supportsForSource: () => false, }; } diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index be49b8dae..cd077f037 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -52,6 +52,10 @@ const sessionMock = vi.hoisted(() => { handleMessage = vi.fn(async () => {}); handleBinaryFrame = vi.fn((_frame: unknown) => {}); supports = vi.fn((capability: string) => this.args.clientCapabilities?.[capability] === true); + updateClientCapabilities = vi.fn((capabilities: Record | null) => { + this.args.clientCapabilities = capabilities; + }); + clearAgentTimelineSubscription = vi.fn(); getClientActivity = vi.fn(() => null); getSessionId = vi.fn(() => "mock-session-id"); resetPeakInflight = vi.fn(() => {}); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index cbe2a13af..62b6b03f4 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -988,6 +988,12 @@ export class VoiceAssistantWebSocketServer { } this.sendToConnection(connection, wrapSessionMessage(msg)); }, + onMessageToSource: (source, msg) => { + if (!connection || !connection.sockets.has(source as WebSocketLike)) { + return; + } + this.sendToClient(source as WebSocketLike, wrapSessionMessage(msg)); + }, onBinaryMessage: (frame) => { if (!connection) { return; @@ -1093,6 +1099,7 @@ export class VoiceAssistantWebSocketServer { sockets: new Set([ws]), externalDisconnectCleanupTimeout: null, }; + session.updateClientCapabilities(clientCapabilities, ws); return connection; } @@ -1163,12 +1170,15 @@ export class VoiceAssistantWebSocketServer { existing.session.updateAppVersion(newAppVersion); } const newClientCapabilities = message.capabilities ?? null; + // COMPAT(selectiveAgentTimeline): added in v0.1.106. Every capable resumed + // hello resets membership before server_info so stale retained-session + // state cannot leak. Remove after 2027-01-12. + existing.session.updateClientCapabilities(newClientCapabilities, ws); if ( JSON.stringify(existing.clientCapabilities ?? null) !== JSON.stringify(newClientCapabilities ?? null) ) { existing.clientCapabilities = newClientCapabilities; - existing.session.updateClientCapabilities(newClientCapabilities); this.syncBrowserToolsClientRegistration(existing); } existing.sockets.add(ws); @@ -1281,6 +1291,8 @@ export class VoiceAssistantWebSocketServer { importSessionWorkspaceTarget: true, // COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106. forgeProviders: true, + // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. + selectiveAgentTimeline: true, }, }; } @@ -1390,6 +1402,7 @@ export class VoiceAssistantWebSocketServer { this.sessions.delete(ws); connection.sockets.delete(ws); + connection.session.clearAgentTimelineSubscription(ws); this.socketIdentities.delete(ws); if (connection.sockets.size === 0) { @@ -1758,7 +1771,7 @@ export class VoiceAssistantWebSocketServer { } const startMs = performance.now(); - await activeConnection.session.handleMessage(message.message); + await activeConnection.session.handleMessage(message.message, ws); const durationMs = performance.now() - startMs; this.recordRequestLatency(message.message.type, durationMs); @@ -2009,21 +2022,36 @@ export class VoiceAssistantWebSocketServer { for (const [clientIndex, { ws }] of clientEntries.entries()) { const shouldNotify = clientIndex === plan.inAppRecipientIndex; const timestamp = new Date().toISOString(); - const message = wrapSessionMessage({ - type: "agent_stream", - payload: { - agentId: params.agentId, - event: { - type: "attention_required", - provider: params.provider, - reason: params.reason, - timestamp, - shouldNotify, - notification, - }, - timestamp, - }, - }); + const connection = this.sessions.get(ws); + const attentionPayload = { + agentId: params.agentId, + reason: params.reason, + timestamp, + shouldNotify, + notification, + }; + const message = wrapSessionMessage( + connection?.session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, ws) + ? { + type: "agent_attention_required", + payload: attentionPayload, + } + : { + type: "agent_stream", + payload: { + agentId: params.agentId, + event: { + type: "attention_required", + provider: params.provider, + reason: params.reason, + timestamp, + shouldNotify, + notification, + }, + timestamp, + }, + }, + ); this.sendToClient(ws, message); }