From 1977d330edefb8d7c8c0f0673e911586d40a6262 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 17 Jul 2026 17:03:34 +0200 Subject: [PATCH] Reduce workspace, agent, and chat sync traffic (#2028) * feat(sync): keep live data scoped and current Only viewed chats receive live timeline rows, while directory state now uses one subscribed bootstrap followed by ordered deltas. Legacy clients and daemons retain their existing behavior through centralized compatibility gates. * fix(sync): preserve selective delivery boundaries Keep selective timeline capability app-owned, union viewed sets across shared sockets, and reconcile directory side effects from accepted state. Visibility and archive suppression now follow their existing authoritative boundaries. * test(app): align browser fixtures with runtime contracts * fix(sync): preserve mixed-client delivery guarantees * fix(sync): finish paged history before advancing * fix(sync): replay live deltas after refresh failures * test(server): model socket capability lookup * test(app): use platform shortcut for split-pane coverage * fix(app): keep hidden timelines dormant * test(app): normalize split-pane shortcut setup * fix(app): preserve sync state across retries * fix(app): preserve background sync state across races * fix directory bootstrap reconciliation edge cases * fix selective timeline compatibility races * fix superseded directory sync races * fix(sync): centralize directory replica ordering Own agent and workspace refresh transactions in HostRuntime so reconnect epochs, buffered updates, and lifecycle invalidation share one ordering boundary. Route timeline metadata and mixed-capability stream delivery through their authoritative runtime sources. * fix(app): keep timeline requests runtime-scoped * fix(app): close directory bootstrap races --- packages/app/e2e/directory-bootstrap.spec.ts | 19 + .../app/e2e/helpers/daemon-websocket-gate.ts | 114 ++ .../helpers/directory-bootstrap-scenario.ts | 155 +++ .../app/e2e/viewed-agent-timelines.spec.ts | 148 +++ .../app/e2e/workspace-model-restart.spec.ts | 2 +- .../workspace-navigation-regression.spec.ts | 57 +- .../rewind/use-rewind-agent-mutation.ts | 4 +- packages/app/src/contexts/session-context.tsx | 967 ++------------- .../session-resume-revalidation.test.ts | 35 + .../contexts/session-resume-revalidation.ts | 16 + ...workspace-directory-reconciliation.test.ts | 63 + .../workspace-directory-reconciliation.ts | 26 + .../diagnostics/app-diagnostic-report.test.ts | 1 + .../hooks/use-agent-initialization.test.ts | 101 +- .../app/src/hooks/use-agent-initialization.ts | 17 +- .../src/hooks/use-load-older-agent-history.ts | 8 +- packages/app/src/panels/agent-panel.tsx | 93 +- .../directory-sync/agent-replica.test.ts | 86 ++ .../runtime/directory-sync/agent-replica.ts | 121 ++ .../src/runtime/directory-sync/index.test.ts | 113 ++ .../app/src/runtime/directory-sync/index.ts | 416 +++++++ .../directory-sync/transaction.test.ts | 100 ++ .../src/runtime/directory-sync/transaction.ts | 70 ++ .../directory-sync/workspace-replica.test.ts | 57 + .../directory-sync/workspace-replica.ts | 93 ++ packages/app/src/runtime/host-runtime.test.ts | 1054 ++++++++++++++++- packages/app/src/runtime/host-runtime.ts | 336 +++--- .../workspace/visible-agent-ids.test.ts | 112 ++ .../screens/workspace/visible-agent-ids.ts | 27 + .../screens/workspace/workspace-screen.tsx | 28 +- packages/app/src/stores/session-store.ts | 41 +- .../fetch-agent-timeline-once.test.ts | 30 + .../src/timeline/fetch-agent-timeline-once.ts | 31 + .../src/timeline/session-stream-reducers.ts | 13 +- .../src/timeline/timeline-sync-plan.test.ts | 25 - .../app/src/timeline/timeline-sync-plan.ts | 13 - .../src/timeline/viewed-timeline-sync.test.ts | 448 +++++++ .../app/src/timeline/viewed-timeline-sync.ts | 321 +++++ .../agent-directory-reconciliation.test.ts | 240 ++++ .../utils/agent-directory-reconciliation.ts | 62 + .../src/utils/agent-directory-sync.test.ts | 165 ++- .../app/src/utils/agent-directory-sync.ts | 131 +- .../utils/agent-directory-update-policy.ts | 21 + .../src/workspace/legacy-daemon-workspaces.ts | 10 +- packages/client/src/daemon-client.test.ts | 93 +- packages/client/src/daemon-client.ts | 63 + packages/protocol/src/client-capabilities.ts | 4 + packages/protocol/src/messages.test.ts | 32 + packages/protocol/src/messages.ts | 41 + .../agent/providers/opencode-agent.test.ts | 8 +- .../selective-timeline-delivery.e2e.test.ts | 287 +++++ packages/server/src/server/session.test.ts | 219 ++++ packages/server/src/server/session.ts | 192 ++- .../src/server/session.workspaces.test.ts | 129 ++ .../websocket-server.notifications.test.ts | 2 + .../websocket-server.relay-reconnect.test.ts | 4 + .../server/src/server/websocket-server.ts | 62 +- 57 files changed, 5783 insertions(+), 1343 deletions(-) create mode 100644 packages/app/e2e/directory-bootstrap.spec.ts create mode 100644 packages/app/e2e/helpers/daemon-websocket-gate.ts create mode 100644 packages/app/e2e/helpers/directory-bootstrap-scenario.ts create mode 100644 packages/app/e2e/viewed-agent-timelines.spec.ts create mode 100644 packages/app/src/contexts/session-resume-revalidation.test.ts create mode 100644 packages/app/src/contexts/session-resume-revalidation.ts create mode 100644 packages/app/src/contexts/workspace-directory-reconciliation.test.ts create mode 100644 packages/app/src/contexts/workspace-directory-reconciliation.ts create mode 100644 packages/app/src/runtime/directory-sync/agent-replica.test.ts create mode 100644 packages/app/src/runtime/directory-sync/agent-replica.ts create mode 100644 packages/app/src/runtime/directory-sync/index.test.ts create mode 100644 packages/app/src/runtime/directory-sync/index.ts create mode 100644 packages/app/src/runtime/directory-sync/transaction.test.ts create mode 100644 packages/app/src/runtime/directory-sync/transaction.ts create mode 100644 packages/app/src/runtime/directory-sync/workspace-replica.test.ts create mode 100644 packages/app/src/runtime/directory-sync/workspace-replica.ts create mode 100644 packages/app/src/screens/workspace/visible-agent-ids.test.ts create mode 100644 packages/app/src/screens/workspace/visible-agent-ids.ts create mode 100644 packages/app/src/timeline/fetch-agent-timeline-once.test.ts create mode 100644 packages/app/src/timeline/fetch-agent-timeline-once.ts create mode 100644 packages/app/src/timeline/viewed-timeline-sync.test.ts create mode 100644 packages/app/src/timeline/viewed-timeline-sync.ts create mode 100644 packages/app/src/utils/agent-directory-reconciliation.test.ts create mode 100644 packages/app/src/utils/agent-directory-reconciliation.ts create mode 100644 packages/app/src/utils/agent-directory-update-policy.ts create mode 100644 packages/server/src/server/selective-timeline-delivery.e2e.test.ts 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); }