From 247983ee87aa3d91dc606a78e5a7feb8dbeac892 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Apr 2026 10:52:19 +0700 Subject: [PATCH] Improve startup provider snapshots and dev harness --- docs/DEVELOPMENT.md | 5 + packages/app/e2e/archive-tab.spec.ts | 26 + packages/app/e2e/global-setup.ts | 42 +- packages/app/e2e/helpers/archive-tab.ts | 24 + packages/app/e2e/helpers/paseo-home-fork.ts | 131 +++++ packages/app/e2e/startup-wire-metrics.spec.ts | 537 ++++++++++++++++++ packages/app/src/components/agent-list.tsx | 58 ++ .../app/src/components/agent-status-bar.tsx | 2 +- .../app/src/hooks/use-agent-form-state.ts | 2 +- .../app/src/hooks/use-agent-history.test.tsx | 262 +++++++++ packages/app/src/hooks/use-agent-history.ts | 155 +++++ .../src/hooks/use-providers-snapshot.test.ts | 71 ++- .../app/src/hooks/use-providers-snapshot.ts | 80 +-- packages/app/src/panels/agent-panel.test.tsx | 99 +++- packages/app/src/panels/agent-panel.tsx | 193 ++++--- packages/app/src/runtime/host-runtime.test.ts | 41 +- packages/app/src/runtime/host-runtime.ts | 27 +- .../app/src/screens/sessions-screen.test.tsx | 158 ++++++ packages/app/src/screens/sessions-screen.tsx | 38 +- .../workspace-agent-visibility.test.ts | 19 + .../workspace/workspace-agent-visibility.ts | 13 +- .../workspace/workspace-header-source.ts | 65 +++ .../screens/workspace/workspace-screen.tsx | 64 ++- .../workspace-source-of-truth.test.ts | 130 ++++- packages/app/src/stores/session-store.ts | 33 +- .../app/src/utils/agent-directory-sync.ts | 34 +- packages/server/scripts/dev-runner.ts | 5 +- .../server/src/client/daemon-client.test.ts | 123 ++++ packages/server/src/client/daemon-client.ts | 40 ++ .../agent/provider-snapshot-manager.test.ts | 86 ++- .../server/agent/provider-snapshot-manager.ts | 68 +-- .../agent/providers/pi-direct-agent.test.ts | 37 +- .../server/agent/providers/pi-direct-agent.ts | 2 +- packages/server/src/server/session.ts | 106 +++- .../src/server/session.workspaces.test.ts | 366 ++++++++++++ packages/server/src/shared/messages.ts | 60 +- .../src/shared/messages.workspaces.test.ts | 46 ++ paseo.json | 2 +- scripts/dev-daemon.sh | 25 + scripts/dev-home.sh | 84 +++ scripts/dev.sh | 20 +- 41 files changed, 2933 insertions(+), 446 deletions(-) create mode 100644 packages/app/e2e/helpers/paseo-home-fork.ts create mode 100644 packages/app/e2e/startup-wire-metrics.spec.ts create mode 100644 packages/app/src/hooks/use-agent-history.test.tsx create mode 100644 packages/app/src/hooks/use-agent-history.ts create mode 100644 packages/app/src/screens/sessions-screen.test.tsx create mode 100755 scripts/dev-daemon.sh create mode 100755 scripts/dev-home.sh diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0c9fb09d3..fbb39ea22 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -22,6 +22,11 @@ PASEO_HOME=~/.paseo-blue npm run dev ``` - `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`. +- In git worktrees, `npm run dev` derives a stable home like `~/.paseo-`. + On first run, it seeds that home from `~/.paseo` by copying agent/project JSON metadata + and `config.json`; actual checkout/worktree directories are not copied. +- `PASEO_DEV_SEED_HOME=/path/to/home npm run dev` seeds from a different source home. +- `PASEO_DEV_RESET_HOME=1 npm run dev` clears and reseeds the derived worktree home. ### Default ports diff --git a/packages/app/e2e/archive-tab.spec.ts b/packages/app/e2e/archive-tab.spec.ts index 96b8d5e2c..813627816 100644 --- a/packages/app/e2e/archive-tab.spec.ts +++ b/packages/app/e2e/archive-tab.spec.ts @@ -4,8 +4,12 @@ import { createTempGitRepo } from "./helpers/workspace"; import { archiveAgentFromDaemon, archiveAgentFromSessions, + clickSessionRow, + closeWorkspaceAgentTab, connectArchiveTabDaemonClient, createIdleAgent, + expectArchivedAgentFocused, + expectSessionRowArchived, expectSessionRowVisible, expectWorkspaceArchiveOutcome, expectWorkspaceTabHidden, @@ -100,4 +104,26 @@ test.describe("Archive tab reconciliation", () => { await passivePage.close(); } }); + + test("clicking an archived session reopens its closed tab focused", async ({ page }) => { + const archived = await createIdleAgent(client, { + cwd: tempRepo.path, + title: `reopen-archived-${randomUUID().slice(0, 8)}`, + }); + const surviving = await createIdleAgent(client, { + cwd: tempRepo.path, + title: `reopen-control-${randomUUID().slice(0, 8)}`, + }); + + await resetSeededPageState(page); + await openWorkspaceWithAgents(page, [archived, surviving]); + await closeWorkspaceAgentTab(page, archived.id); + await archiveAgentFromDaemon(client, archived.id); + await openSessions(page); + await expectSessionRowArchived(page, archived.title); + + await clickSessionRow(page, archived.title); + + await expectArchivedAgentFocused(page, archived.id); + }); }); diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 2962d1336..e031f25c5 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -6,6 +6,7 @@ import path from "node:path"; import net from "node:net"; import { Buffer } from "node:buffer"; import dotenv from "dotenv"; +import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork"; type WaitForServerOptions = { host?: string; @@ -185,6 +186,17 @@ let paseoHome: string | null = null; let fakeGhBinDir: string | null = null; let relayProcess: ChildProcess | null = null; +function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + if (trimmed === "current") { + return resolvePaseoHomePath("~/.paseo"); + } + return resolvePaseoHomePath(trimmed); +} + type OfferPayload = { v: 2; serverId: string; @@ -322,12 +334,36 @@ export default async function globalSetup() { const port = await getAvailablePort(); let relayPort = 0; const metroPort = await getAvailablePort(); - paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")); + const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME); + const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1"; + paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"))); fakeGhBinDir = await createFakeGhBin(); let relayLineBuffer = createLineBuffer(); const metroLineBuffer = createLineBuffer(); const daemonLineBuffer = createLineBuffer(); + const forkSourceHome = resolveOptionalPaseoHomeEnv(process.env.E2E_FORK_PASEO_HOME_FROM); + if (forkSourceHome) { + const forkResult = await forkPaseoHomeMetadata({ + sourceHome: forkSourceHome, + targetHome: paseoHome, + }); + process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome; + process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome; + process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles); + process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes); + console.log( + `[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` + + `(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` + + `${forkResult.copiedBytes} bytes)`, + ); + if (forkResult.skippedMissing.length > 0) { + console.warn( + `[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`, + ); + } + } + const cleanup = async () => { await Promise.all([ stopProcess(daemonProcess), @@ -337,9 +373,11 @@ export default async function globalSetup() { daemonProcess = null; metroProcess = null; relayProcess = null; - if (paseoHome) { + if (paseoHome && shouldRemovePaseoHome) { await rm(paseoHome, { recursive: true, force: true }); paseoHome = null; + } else if (paseoHome) { + console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`); } if (fakeGhBinDir) { await rm(fakeGhBinDir, { recursive: true, force: true }); diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 01d5b3e0f..175e42cdb 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -231,6 +231,24 @@ export async function expectWorkspaceArchiveOutcome( await expectWorkspaceTabVisible(page, input.survivingAgentId); } +export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise { + const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({ + visible: true, + }); + await expect(closeButton.first()).toBeVisible({ timeout: 30_000 }); + await closeButton.first().click(); + await expectWorkspaceTabHidden(page, agentId); +} + +export async function expectArchivedAgentFocused(page: Page, agentId: string): Promise { + await expectWorkspaceTabVisible(page, agentId); + await expect( + page.getByText("This agent is archived").filter({ visible: true }).first(), + ).toBeVisible({ + timeout: 30_000, + }); +} + export async function reloadWorkspace(page: Page, workspaceId: string): Promise { const serverId = getServerId(); await page.goto(buildHostWorkspaceRoute(serverId, workspaceId)); @@ -261,6 +279,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 }); } +export async function clickSessionRow(page: Page, title: string): Promise { + const row = getSessionRowByTitle(page, title); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); +} + export async function archiveAgentFromSessions( page: Page, input: { agentId: string; title: string }, diff --git a/packages/app/e2e/helpers/paseo-home-fork.ts b/packages/app/e2e/helpers/paseo-home-fork.ts new file mode 100644 index 000000000..0b4f2049e --- /dev/null +++ b/packages/app/e2e/helpers/paseo-home-fork.ts @@ -0,0 +1,131 @@ +import { existsSync } from "node:fs"; +import { copyFile, mkdir, readdir, rm, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +export type PaseoHomeMetadataForkResult = { + sourceHome: string; + targetHome: string; + agentFiles: number; + agentBytes: number; + projectFiles: number; + projectBytes: number; + copiedFiles: number; + copiedBytes: number; + skippedMissing: string[]; +}; + +type CopyStats = { + files: number; + bytes: number; + skippedMissing: string[]; +}; + +export function resolvePaseoHomePath(value: string): string { + if (value === "~") { + return homedir(); + } + if (value.startsWith("~/")) { + return path.join(homedir(), value.slice(2)); + } + return path.resolve(value); +} + +async function copyJsonTree(sourceDir: string, targetDir: string): Promise { + if (!existsSync(sourceDir)) { + return { files: 0, bytes: 0, skippedMissing: [sourceDir] }; + } + + const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] }; + const entries = await readdir(sourceDir, { withFileTypes: true }); + await mkdir(targetDir, { recursive: true }); + + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + const nested = await copyJsonTree(sourcePath, targetPath); + stats.files += nested.files; + stats.bytes += nested.bytes; + stats.skippedMissing.push(...nested.skippedMissing); + continue; + } + + if (!entry.isFile() || !entry.name.endsWith(".json")) { + continue; + } + + await mkdir(path.dirname(targetPath), { recursive: true }); + await copyFile(sourcePath, targetPath); + const fileStat = await stat(sourcePath); + stats.files += 1; + stats.bytes += fileStat.size; + } + + return stats; +} + +async function copyProjectRegistryFiles( + sourceHome: string, + targetHome: string, +): Promise { + const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] }; + const sourceProjectsDir = path.join(sourceHome, "projects"); + const targetProjectsDir = path.join(targetHome, "projects"); + await mkdir(targetProjectsDir, { recursive: true }); + + for (const fileName of ["projects.json", "workspaces.json"]) { + const sourcePath = path.join(sourceProjectsDir, fileName); + const targetPath = path.join(targetProjectsDir, fileName); + if (!existsSync(sourcePath)) { + stats.skippedMissing.push(sourcePath); + continue; + } + await copyFile(sourcePath, targetPath); + const fileStat = await stat(sourcePath); + stats.files += 1; + stats.bytes += fileStat.size; + } + + return stats; +} + +export async function forkPaseoHomeMetadata(input: { + sourceHome: string; + targetHome: string; +}): Promise { + const sourceHome = resolvePaseoHomePath(input.sourceHome); + const targetHome = resolvePaseoHomePath(input.targetHome); + + if (sourceHome === targetHome) { + throw new Error("Refusing to fork Paseo metadata onto the same PASEO_HOME."); + } + + await mkdir(targetHome, { recursive: true }); + + // Reset only the copied metadata surface. In particular, do not copy or remove + // worktrees here: forked workspace records should continue to point at the + // original checkout/worktree paths from the source home. + await rm(path.join(targetHome, "agents"), { recursive: true, force: true }); + await rm(path.join(targetHome, "projects", "projects.json"), { force: true }); + await rm(path.join(targetHome, "projects", "workspaces.json"), { force: true }); + + const agents = await copyJsonTree( + path.join(sourceHome, "agents"), + path.join(targetHome, "agents"), + ); + const projects = await copyProjectRegistryFiles(sourceHome, targetHome); + + return { + sourceHome, + targetHome, + agentFiles: agents.files, + agentBytes: agents.bytes, + projectFiles: projects.files, + projectBytes: projects.bytes, + copiedFiles: agents.files + projects.files, + copiedBytes: agents.bytes + projects.bytes, + skippedMissing: [...agents.skippedMissing, ...projects.skippedMissing], + }; +} diff --git a/packages/app/e2e/startup-wire-metrics.spec.ts b/packages/app/e2e/startup-wire-metrics.spec.ts new file mode 100644 index 000000000..39b194d26 --- /dev/null +++ b/packages/app/e2e/startup-wire-metrics.spec.ts @@ -0,0 +1,537 @@ +import { Buffer } from "node:buffer"; +import type { CDPSession, Page, TestInfo } from "@playwright/test"; +import { expect, test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { waitForSidebarHydration } from "./helpers/workspace-ui"; + +type WireDirection = "sent" | "received"; +type WirePhase = "startup" | "workspace_clicks"; + +type ParsedWireMessage = { + type: string | null; + requestId: string | null; + entryCount: number | null; + hasMore: boolean | null; + providerEntries: ProviderSnapshotWireEntry[] | null; +}; + +type WireFrameRecord = ParsedWireMessage & { + phase: WirePhase; + direction: WireDirection; + bytes: number; +}; + +type ProviderSnapshotWireEntry = { + provider: string; + status: string | null; + modelCount: number; + modeCount: number; + bytes: number; +}; + +type WebSocketFrameEvent = { + requestId: string; + response: { + opcode: number; + payloadData: string; + }; +}; + +type WireSummary = { + totalFrames: number; + totalBytes: number; + byDirection: Record; + byPhase: Record< + WirePhase, + { + frames: number; + bytes: number; + byType: Array<{ type: string; frames: number; bytes: number }>; + rpcCounts: Array<{ requestType: string; count: number }>; + rpcs: Array<{ requestType: string; requestId: string; responseType: string | null }>; + } + >; + fetchPages: Array<{ + phase: WirePhase; + type: string; + requestId: string | null; + entries: number | null; + hasMore: boolean | null; + bytes: number; + }>; + clickedWorkspaces: Array<{ testId: string; frames: number; bytes: number }>; + providerSnapshots: Array<{ + phase: WirePhase; + type: string; + requestId: string | null; + totalModels: number; + totalModes: number; + bytes: number; + providers: ProviderSnapshotWireEntry[]; + }>; + providerSnapshotTotals: Array<{ + phase: WirePhase; + provider: string; + frames: number; + bytes: number; + maxModels: number; + maxModes: number; + statuses: string[]; + }>; + fork: { + sourceHome: string | null; + targetHome: string | null; + copiedFiles: number | null; + copiedBytes: number | null; + }; +}; + +class WireMonitor { + private phase: WirePhase = "startup"; + private session: CDPSession | null = null; + readonly records: WireFrameRecord[] = []; + + async start(page: Page): Promise { + this.session = await page.context().newCDPSession(page); + await this.session.send("Network.enable"); + this.session.on("Network.webSocketFrameSent", (event: WebSocketFrameEvent) => { + this.record("sent", event); + }); + this.session.on("Network.webSocketFrameReceived", (event: WebSocketFrameEvent) => { + this.record("received", event); + }); + } + + setPhase(phase: WirePhase): void { + this.phase = phase; + } + + hasCompletedStartupFetches(): boolean { + return ( + this.records.some( + (record) => + record.direction === "received" && + record.type === "fetch_agents_response" && + record.hasMore === false, + ) && + this.records.some( + (record) => + record.direction === "received" && + record.type === "fetch_workspaces_response" && + record.hasMore === false, + ) + ); + } + + summarize(clickedWorkspaces: WireSummary["clickedWorkspaces"]): WireSummary { + return { + totalFrames: this.records.length, + totalBytes: sumBytes(this.records), + byDirection: { + sent: summarizeDirection(this.records, "sent"), + received: summarizeDirection(this.records, "received"), + }, + byPhase: { + startup: summarizePhase(this.records, "startup"), + workspace_clicks: summarizePhase(this.records, "workspace_clicks"), + }, + fetchPages: this.records + .filter( + (record) => + record.direction === "received" && + (record.type === "fetch_agents_response" || + record.type === "fetch_workspaces_response"), + ) + .map((record) => ({ + phase: record.phase, + type: record.type ?? "unknown", + requestId: record.requestId, + entries: record.entryCount, + hasMore: record.hasMore, + bytes: record.bytes, + })), + clickedWorkspaces, + providerSnapshots: this.records + .filter((record) => record.direction === "received" && record.providerEntries) + .map((record) => ({ + phase: record.phase, + type: record.type ?? "unknown", + requestId: record.requestId, + totalModels: sumProviderModels(record.providerEntries ?? []), + totalModes: sumProviderModes(record.providerEntries ?? []), + bytes: record.bytes, + providers: record.providerEntries ?? [], + })), + providerSnapshotTotals: summarizeProviderSnapshots(this.records), + fork: { + sourceHome: process.env.E2E_FORK_SOURCE_PASEO_HOME ?? null, + targetHome: process.env.E2E_FORK_TARGET_PASEO_HOME ?? null, + copiedFiles: parseOptionalNumber(process.env.E2E_FORK_COPIED_FILES), + copiedBytes: parseOptionalNumber(process.env.E2E_FORK_COPIED_BYTES), + }, + }; + } + + private record(direction: WireDirection, event: WebSocketFrameEvent): void { + if (event.response.opcode !== 1) { + this.records.push({ + phase: this.phase, + direction, + bytes: Buffer.byteLength(event.response.payloadData), + type: `opcode:${event.response.opcode}`, + requestId: null, + entryCount: null, + hasMore: null, + providerEntries: null, + }); + return; + } + + this.records.push({ + phase: this.phase, + direction, + bytes: Buffer.byteLength(event.response.payloadData, "utf8"), + ...parseWireMessage(event.response.payloadData), + }); + } +} + +function parseOptionalNumber(value: string | undefined): number | null { + if (!value) { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseWireMessage(payloadData: string): ParsedWireMessage { + try { + const parsed = JSON.parse(payloadData) as unknown; + if (!parsed || typeof parsed !== "object") { + return emptyParsedWireMessage(); + } + const envelope = parsed as { + type?: unknown; + message?: unknown; + }; + const unwrapped = + envelope.type === "session" && envelope.message && typeof envelope.message === "object" + ? envelope.message + : parsed; + const message = unwrapped as { + type?: unknown; + requestId?: unknown; + payload?: { + requestId?: unknown; + entries?: unknown; + agents?: unknown; + pageInfo?: { hasMore?: unknown }; + }; + }; + const entries = readMessageEntries(message.payload); + const hasMore = readMessageHasMore(message.payload); + const messageType = typeof message.type === "string" ? message.type : null; + const providerEntries = isProviderSnapshotMessage(messageType) + ? parseProviderSnapshotEntries(message.payload) + : null; + return { + type: messageType, + requestId: readMessageRequestId(message), + entryCount: entries ? entries.length : null, + hasMore, + providerEntries, + }; + } catch { + return emptyParsedWireMessage(); + } +} + +function readMessageRequestId(message: { + requestId?: unknown; + payload?: { requestId?: unknown }; +}): string | null { + if (typeof message.requestId === "string") { + return message.requestId; + } + if (typeof message.payload?.requestId === "string") { + return message.payload.requestId; + } + return null; +} + +function isProviderSnapshotMessage(messageType: string | null): boolean { + return ( + messageType === "get_providers_snapshot_response" || messageType === "providers_snapshot_update" + ); +} + +function readMessageEntries( + payload: + | { + entries?: unknown; + agents?: unknown; + } + | undefined, +): unknown[] | null { + if (Array.isArray(payload?.entries)) { + return payload.entries; + } + if (Array.isArray(payload?.agents)) { + return payload.agents; + } + return null; +} + +function readMessageHasMore( + payload: { pageInfo?: { hasMore?: unknown } } | undefined, +): boolean | null { + return typeof payload?.pageInfo?.hasMore === "boolean" ? payload.pageInfo.hasMore : null; +} + +function emptyParsedWireMessage(): ParsedWireMessage { + return { + type: null, + requestId: null, + entryCount: null, + hasMore: null, + providerEntries: null, + }; +} + +function parseProviderSnapshotEntries(payload: unknown): ProviderSnapshotWireEntry[] | null { + if (!payload || typeof payload !== "object") { + return null; + } + const entries = (payload as { entries?: unknown }).entries; + if (!Array.isArray(entries)) { + return null; + } + + const providerEntries: ProviderSnapshotWireEntry[] = []; + for (const entry of entries) { + if (!entry || typeof entry !== "object") { + continue; + } + const provider = (entry as { provider?: unknown }).provider; + if (typeof provider !== "string") { + continue; + } + const models = (entry as { models?: unknown }).models; + const modes = (entry as { modes?: unknown }).modes; + const status = (entry as { status?: unknown }).status; + providerEntries.push({ + provider, + status: typeof status === "string" ? status : null, + modelCount: Array.isArray(models) ? models.length : 0, + modeCount: Array.isArray(modes) ? modes.length : 0, + bytes: Buffer.byteLength(JSON.stringify(entry), "utf8"), + }); + } + + return providerEntries.length > 0 ? providerEntries : null; +} + +function sumBytes(records: WireFrameRecord[]): number { + return records.reduce((sum, record) => sum + record.bytes, 0); +} + +function summarizeDirection(records: WireFrameRecord[], direction: WireDirection) { + const selected = records.filter((record) => record.direction === direction); + return { + frames: selected.length, + bytes: sumBytes(selected), + }; +} + +function summarizePhase(records: WireFrameRecord[], phase: WirePhase) { + const selected = records.filter((record) => record.phase === phase); + return { + frames: selected.length, + bytes: sumBytes(selected), + byType: summarizeByType(selected), + rpcCounts: summarizeRpcCounts(selected), + rpcs: summarizeRpcs(selected), + }; +} + +function summarizeByType(records: WireFrameRecord[]): Array<{ + type: string; + frames: number; + bytes: number; +}> { + const byType = new Map(); + for (const record of records) { + const type = record.type ?? "unknown"; + const current = byType.get(type) ?? { frames: 0, bytes: 0 }; + current.frames += 1; + current.bytes += record.bytes; + byType.set(type, current); + } + return [...byType.entries()] + .map(([type, value]) => ({ type, ...value })) + .sort((left, right) => right.bytes - left.bytes); +} + +function summarizeRpcs(records: WireFrameRecord[]): WireSummary["byPhase"][WirePhase]["rpcs"] { + const responsesByRequestId = new Map(); + for (const record of records) { + if (record.direction !== "received" || !record.requestId) { + continue; + } + responsesByRequestId.set(record.requestId, record.type ?? "unknown"); + } + + return records + .filter((record) => record.direction === "sent" && record.requestId && record.type) + .map((record) => ({ + requestType: record.type ?? "unknown", + requestId: record.requestId ?? "", + responseType: responsesByRequestId.get(record.requestId ?? "") ?? null, + })); +} + +function summarizeRpcCounts( + records: WireFrameRecord[], +): Array<{ requestType: string; count: number }> { + const counts = new Map(); + for (const record of records) { + if (record.direction !== "sent" || !record.type || !record.requestId) { + continue; + } + counts.set(record.type, (counts.get(record.type) ?? 0) + 1); + } + return [...counts.entries()] + .map(([requestType, count]) => ({ requestType, count })) + .sort( + (left, right) => + right.count - left.count || left.requestType.localeCompare(right.requestType), + ); +} + +function sumProviderModels(entries: ProviderSnapshotWireEntry[]): number { + return entries.reduce((sum, entry) => sum + entry.modelCount, 0); +} + +function sumProviderModes(entries: ProviderSnapshotWireEntry[]): number { + return entries.reduce((sum, entry) => sum + entry.modeCount, 0); +} + +function summarizeProviderSnapshots( + records: WireFrameRecord[], +): WireSummary["providerSnapshotTotals"] { + const byProvider = new Map< + string, + { + phase: WirePhase; + provider: string; + frames: number; + bytes: number; + maxModels: number; + maxModes: number; + statuses: Set; + } + >(); + + for (const record of records) { + if (record.direction !== "received" || !record.providerEntries) { + continue; + } + for (const entry of record.providerEntries) { + const key = `${record.phase}:${entry.provider}`; + const current = byProvider.get(key) ?? { + phase: record.phase, + provider: entry.provider, + frames: 0, + bytes: 0, + maxModels: 0, + maxModes: 0, + statuses: new Set(), + }; + current.frames += 1; + current.bytes += entry.bytes; + current.maxModels = Math.max(current.maxModels, entry.modelCount); + current.maxModes = Math.max(current.maxModes, entry.modeCount); + if (entry.status) { + current.statuses.add(entry.status); + } + byProvider.set(key, current); + } + } + + return [...byProvider.values()] + .map((entry) => ({ + phase: entry.phase, + provider: entry.provider, + frames: entry.frames, + bytes: entry.bytes, + maxModels: entry.maxModels, + maxModes: entry.maxModes, + statuses: [...entry.statuses].sort(), + })) + .sort((left, right) => right.bytes - left.bytes); +} + +async function attachSummary(testInfo: TestInfo, summary: WireSummary): Promise { + await testInfo.attach("startup-wire-metrics.json", { + body: JSON.stringify(summary, null, 2), + contentType: "application/json", + }); +} + +test.describe("ad hoc startup wire metrics", () => { + test.skip( + process.env.E2E_WIRE_METRICS !== "1", + "Set E2E_WIRE_METRICS=1 to run this ad hoc measurement.", + ); + + test("measures startup hydration and workspace navigation websocket traffic", async ({ + page, + }, testInfo) => { + test.setTimeout(180_000); + + const monitor = new WireMonitor(); + await monitor.start(page); + + await gotoAppShell(page); + await waitForSidebarHydration(page, 120_000); + await expect.poll(() => monitor.hasCompletedStartupFetches(), { timeout: 120_000 }).toBe(true); + await page.waitForTimeout(1_000); + + const workspaceTestIds = await page + .locator('[data-testid^="sidebar-workspace-row-"]:visible') + .evaluateAll((elements) => + elements + .slice(0, 3) + .map((element) => element.getAttribute("data-testid")) + .filter((value): value is string => Boolean(value)), + ); + + monitor.setPhase("workspace_clicks"); + const clickedWorkspaces: WireSummary["clickedWorkspaces"] = []; + for (const testId of workspaceTestIds) { + const row = page.getByTestId(testId); + if (!(await row.isVisible().catch(() => false))) { + continue; + } + const beforeFrames = monitor.records.length; + const beforeBytes = sumBytes(monitor.records); + await row.click(); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); + await page.waitForTimeout(1_000); + clickedWorkspaces.push({ + testId, + frames: monitor.records.length - beforeFrames, + bytes: sumBytes(monitor.records) - beforeBytes, + }); + } + + const summary = monitor.summarize(clickedWorkspaces); + await attachSummary(testInfo, summary); + console.log("PASEO_STARTUP_WIRE_METRICS_BEGIN"); + console.log(JSON.stringify(summary, null, 2)); + console.log("PASEO_STARTUP_WIRE_METRICS_END"); + + expect(summary.byPhase.startup.byType.length).toBeGreaterThan(0); + expect(clickedWorkspaces.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 9db2e94c2..cae5d2584 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -21,6 +21,7 @@ import { getProviderIcon } from "@/components/provider-icons"; import { buildHostAgentDetailRoute } from "@/utils/host-routes"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import type { Agent } from "@/stores/session-store"; interface AgentListProps { agents: AggregatedAgent[]; @@ -37,6 +38,62 @@ type FlatListItem = | { type: "header"; key: string; title: string } | { type: "agent"; key: string; agent: AggregatedAgent }; +function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent { + return { + serverId: agent.serverId, + id: agent.id, + provider: agent.provider, + status: agent.status, + createdAt: agent.createdAt, + updatedAt: agent.lastActivityAt, + lastUserMessageAt: null, + lastActivityAt: agent.lastActivityAt, + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: { + provider: agent.provider, + sessionId: null, + }, + title: agent.title, + cwd: agent.cwd, + model: null, + thinkingOptionId: null, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason, + attentionTimestamp: agent.attentionTimestamp, + archivedAt: agent.archivedAt, + labels: agent.labels, + }; +} + +function rememberArchivedAgentDetail(agent: AggregatedAgent) { + if (!agent.archivedAt) { + return; + } + + useSessionStore.getState().setAgentDetails(agent.serverId, (previous) => { + const existing = previous.get(agent.id); + const next = new Map(previous); + next.set(agent.id, { + ...buildHistoricalAgentDetail(agent), + ...existing, + archivedAt: existing?.archivedAt ?? agent.archivedAt, + cwd: existing?.cwd ?? agent.cwd, + }); + return next; + }); +} + function deriveDateSectionLabel(lastActivityAt: Date): string { const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); @@ -251,6 +308,7 @@ export function AgentList({ return; } + rememberArchivedAgentDetail(agent); const route = prepareWorkspaceTab({ serverId, workspaceId, diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 0f40f0046..0c7682680 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -878,7 +878,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({ entries: snapshotEntries, isLoading: snapshotIsLoading, refetchIfStale: refetchSnapshotIfStale, - } = useProvidersSnapshot(serverId, agent?.cwd); + } = useProvidersSnapshot(serverId); const snapshotSelectedEntry = useMemo(() => { if (!snapshotEntries || !agent?.provider) { diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 1363a233b..e39ae2b97 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -418,7 +418,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg error: snapshotError, refresh: refreshSnapshot, refetchIfStale: refetchSnapshotIfStale, - } = useProvidersSnapshot(formState.serverId, formState.workingDir); + } = useProvidersSnapshot(formState.serverId); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const snapshotProviderDefinitions = useMemo( diff --git a/packages/app/src/hooks/use-agent-history.test.tsx b/packages/app/src/hooks/use-agent-history.test.tsx new file mode 100644 index 000000000..6a4d2b258 --- /dev/null +++ b/packages/app/src/hooks/use-agent-history.test.tsx @@ -0,0 +1,262 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + DaemonClient, + FetchAgentHistoryEntry, + FetchAgentHistoryOptions, +} from "@server/client/daemon-client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { useAgentHistory } from "./use-agent-history"; + +const { mockClient, mockRuntimeStore } = vi.hoisted(() => { + const mockClient = { + fetchAgentHistory: vi.fn(), + }; + const mockRuntimeStore = { + refreshAgentDirectory: vi.fn(), + }; + return { mockClient, mockRuntimeStore }; +}); + +vi.mock("@/runtime/host-runtime", () => ({ + getHostRuntimeStore: () => mockRuntimeStore, + useHostRuntimeClient: () => mockClient, + useHostRuntimeIsConnected: () => true, + useHosts: () => [{ serverId: "server-1", label: "Local" }], +})); + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); +} + +function renderAgentHistoryHook(options?: { enabled?: boolean }) { + const queryClient = createQueryClient(); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + return renderHook(() => useAgentHistory({ serverId: "server-1", enabled: options?.enabled }), { + wrapper, + }); +} + +function makeHistoryPayload(input: { + entries: FetchAgentHistoryEntry[]; + hasMore?: boolean; + nextCursor?: string | null; +}): Awaited> { + return { + requestId: "req_history", + entries: input.entries, + pageInfo: { + nextCursor: input.nextCursor ?? null, + prevCursor: null, + hasMore: input.hasMore ?? false, + }, + }; +} + +function makeHistoryEntry(input: { + id: string; + cwd: string; + updatedAt: string; + title?: string | null; + archivedAt?: string | null; +}): FetchAgentHistoryEntry { + return { + agent: { + id: input.id, + provider: "codex", + status: "closed", + createdAt: input.updatedAt, + updatedAt: input.updatedAt, + lastUserMessageAt: null, + lastError: undefined, + runtimeInfo: { + provider: "codex", + sessionId: null, + }, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: input.title ?? null, + cwd: input.cwd, + model: null, + thinkingOptionId: null, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: input.archivedAt ?? null, + labels: {}, + }, + project: { + projectKey: input.cwd, + projectName: "workspace", + checkout: { + cwd: input.cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }; +} + +function makeActiveAgent(): Agent { + const timestamp = new Date("2026-04-01T10:00:00.000Z"); + return { + serverId: "server-1", + id: "active-1", + provider: "codex", + status: "idle", + createdAt: timestamp, + updatedAt: timestamp, + lastUserMessageAt: null, + lastActivityAt: timestamp, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Active", + cwd: "/repo", + model: null, + labels: {}, + archivedAt: null, + }; +} + +afterEach(() => { + mockClient.fetchAgentHistory.mockReset(); + mockRuntimeStore.refreshAgentDirectory.mockReset(); + useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); +}); + +describe("useAgentHistory", () => { + it("loads history one page at a time without refreshing active agents", async () => { + mockClient.fetchAgentHistory + .mockResolvedValueOnce( + makeHistoryPayload({ + entries: [ + makeHistoryEntry({ + id: "history-1", + cwd: "/repo", + updatedAt: "2026-04-02T10:00:00.000Z", + title: "History one", + }), + ], + hasMore: true, + nextCursor: "cursor-2", + }), + ) + .mockResolvedValueOnce( + makeHistoryPayload({ + entries: [ + makeHistoryEntry({ + id: "history-2", + cwd: "/repo", + updatedAt: "2026-04-01T10:00:00.000Z", + title: "History two", + archivedAt: "2026-04-01T10:05:00.000Z", + }), + ], + }), + ); + + act(() => { + useSessionStore + .getState() + .initializeSession("server-1", mockClient as unknown as DaemonClient); + useSessionStore.getState().setAgents("server-1", new Map([["active-1", makeActiveAgent()]])); + }); + + const { result } = renderAgentHistoryHook(); + + await waitFor(() => { + expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1); + }); + + expect(mockClient.fetchAgentHistory.mock.calls.map(([options]) => options)).toEqual([ + { + sort: [{ key: "updated_at", direction: "desc" }], + page: { limit: 200 }, + }, + ] satisfies FetchAgentHistoryOptions[]); + await waitFor(() => { + expect(result.current.agents.map((agent) => agent.id)).toEqual(["history-1"]); + }); + expect(result.current.hasMore).toBe(true); + + await act(async () => { + result.current.loadMore(); + }); + + await waitFor(() => { + expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(2); + }); + + expect(mockClient.fetchAgentHistory.mock.calls.at(-1)?.[0]).toEqual({ + sort: [{ key: "updated_at", direction: "desc" }], + page: { limit: 200, cursor: "cursor-2" }, + } satisfies FetchAgentHistoryOptions); + await waitFor(() => { + expect(result.current.agents.map((agent) => agent.id)).toEqual(["history-1", "history-2"]); + }); + expect(result.current.hasMore).toBe(false); + expect( + Array.from(useSessionStore.getState().sessions["server-1"]?.agents.keys() ?? []), + ).toEqual(["active-1"]); + expect(mockRuntimeStore.refreshAgentDirectory).not.toHaveBeenCalled(); + }); + + it("waits until history is enabled before calling the history RPC", async () => { + mockClient.fetchAgentHistory.mockResolvedValue(makeHistoryPayload({ entries: [] })); + + const queryClient = createQueryClient(); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + let enabled = false; + const { rerender } = renderHook(() => useAgentHistory({ serverId: "server-1", enabled }), { + wrapper, + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(mockClient.fetchAgentHistory).not.toHaveBeenCalled(); + + enabled = true; + rerender(); + + await waitFor(() => { + expect(mockClient.fetchAgentHistory).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts new file mode 100644 index 000000000..f132b258f --- /dev/null +++ b/packages/app/src/hooks/use-agent-history.ts @@ -0,0 +1,155 @@ +import type { + DaemonClient, + FetchAgentHistoryOptions, + FetchAgentHistoryPageInfo, +} from "@server/client/daemon-client"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; +import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; + +const AGENT_HISTORY_PAGE_LIMIT = 200; +const AGENT_HISTORY_SORT: NonNullable = [ + { key: "updated_at", direction: "desc" }, +]; + +export interface AgentHistoryResult { + agents: AggregatedAgent[]; + isLoading: boolean; + isInitialLoad: boolean; + isRevalidating: boolean; + hasMore: boolean; + isLoadingMore: boolean; + refreshAll: () => void; + loadMore: () => void; +} + +type AgentHistoryPage = { + agents: AggregatedAgent[]; + pageInfo: FetchAgentHistoryPageInfo; +}; + +export function agentHistoryQueryKey(serverId: string | null) { + return ["agentHistory", serverId] as const; +} + +async function fetchAgentHistoryPage(input: { + client: DaemonClient; + serverId: string; + cursor: string | null; +}): Promise { + const payload = await input.client.fetchAgentHistory({ + sort: AGENT_HISTORY_SORT, + page: input.cursor + ? { limit: AGENT_HISTORY_PAGE_LIMIT, cursor: input.cursor } + : { limit: AGENT_HISTORY_PAGE_LIMIT }, + }); + + const { agents } = buildAgentDirectoryState({ + serverId: input.serverId, + entries: payload.entries, + }); + + return { + agents: Array.from(agents.values(), (agent) => ({ + id: agent.id, + serverId: input.serverId, + serverLabel: input.serverId, + title: agent.title ?? null, + status: agent.status, + lastActivityAt: agent.lastActivityAt, + cwd: agent.cwd, + provider: agent.provider, + pendingPermissionCount: agent.pendingPermissions.length, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason, + attentionTimestamp: agent.attentionTimestamp ?? null, + archivedAt: agent.archivedAt ?? null, + createdAt: agent.createdAt, + labels: agent.labels, + })), + pageInfo: payload.pageInfo, + }; +} + +export function useAgentHistory(options: { + serverId?: string | null; + enabled?: boolean; +}): AgentHistoryResult { + const daemons = useHosts(); + const serverId = useMemo(() => { + const value = options.serverId; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + }, [options.serverId]); + const enabled = options.enabled ?? true; + const client = useHostRuntimeClient(serverId ?? ""); + const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const queryKey = useMemo(() => agentHistoryQueryKey(serverId), [serverId]); + const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId; + + const historyQuery = useInfiniteQuery< + AgentHistoryPage, + Error, + { pages: AgentHistoryPage[] }, + ReturnType, + string | null + >({ + queryKey, + enabled: Boolean(enabled && serverId && client && isConnected), + staleTime: 30_000, + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => + lastPage.pageInfo.hasMore ? lastPage.pageInfo.nextCursor : null, + queryFn: async ({ pageParam }) => { + if (!serverId || !client) { + throw new Error("Host is not connected"); + } + return fetchAgentHistoryPage({ client, serverId, cursor: pageParam }); + }, + }); + const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isLoading, refetch } = + historyQuery; + + const refreshAll = useCallback(() => { + if (!serverId || !client || !isConnected) { + return; + } + void refetch(); + }, [client, isConnected, refetch, serverId]); + + const loadMore = useCallback(() => { + if (!serverId || !client || !isConnected || !hasNextPage || isFetchingNextPage) { + return; + } + void fetchNextPage(); + }, [client, fetchNextPage, hasNextPage, isConnected, isFetchingNextPage, serverId]); + + const agents = useMemo( + () => + (data?.pages ?? []) + .flatMap((page) => page.agents) + .map((agent) => ({ + ...agent, + serverLabel: serverLabel ?? agent.serverLabel, + })), + [data?.pages, serverLabel], + ); + const isInitialLoad = isLoading && agents.length === 0; + const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0; + + return { + agents, + isLoading, + isInitialLoad, + isRevalidating, + hasMore: Boolean(hasNextPage), + isLoadingMore: isFetchingNextPage, + refreshAll, + loadMore, + }; +} + +export const __private__ = { + fetchAgentHistoryPage, +}; diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index eb5183795..de88fa487 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -9,11 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { DaemonClient } from "@server/client/daemon-client"; import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; import { useSessionStore } from "@/stores/session-store"; -import { - providersSnapshotQueryKey, - shouldApplyProvidersSnapshotUpdate, - useProvidersSnapshot, -} from "./use-providers-snapshot"; +import { providersSnapshotQueryKey, useProvidersSnapshot } from "./use-providers-snapshot"; type ProviderSnapshotUpdateMessage = { type: "providers_snapshot_update"; @@ -80,12 +76,12 @@ function enableProvidersSnapshot(): void { }); } -function renderProvidersSnapshotHook(cwd?: string | null) { +function renderProvidersSnapshotHook() { const queryClient = createQueryClient(); const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); - return renderHook(() => useProvidersSnapshot(serverId, cwd), { wrapper }); + return renderHook(() => useProvidersSnapshot(serverId), { wrapper }); } const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const; @@ -124,7 +120,10 @@ async function waitForSnapshotEntries( }); } -async function emitProvidersSnapshotUpdate(entries: ProviderSnapshotEntry[]): Promise { +async function emitProvidersSnapshotUpdate( + entries: ProviderSnapshotEntry[], + cwd = "/repo", +): Promise { const listener = snapshotUpdateListeners.at(-1); expect(listener).toBeDefined(); @@ -132,7 +131,7 @@ async function emitProvidersSnapshotUpdate(entries: ProviderSnapshotEntry[]): Pr listener?.({ type: "providers_snapshot_update", payload: { - cwd: "/repo", + cwd, entries, generatedAt: "2026-01-01T00:00:01.000Z", }, @@ -155,23 +154,8 @@ afterEach(() => { }); describe("providers snapshot hook cache scope", () => { - it("uses no cwd in the settings query key", () => { - expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, null]); - }); - - it("accepts concrete home update events for settings snapshots", () => { - expect(shouldApplyProvidersSnapshotUpdate(undefined, "/Users/alex")).toBe(true); - expect(shouldApplyProvidersSnapshotUpdate(null, "/home/alex")).toBe(true); - }); - - it("keeps workspace snapshot updates scoped to matching cwd keys", () => { - expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex/project")).toBe( - true, - ); - expect( - shouldApplyProvidersSnapshotUpdate("/Users/alex/project-a", "/Users/alex/project-b"), - ).toBe(false); - expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex")).toBe(false); + it("uses a global query key without cwd", () => { + expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId]); }); it("sends no cwd for settings snapshot loads and refreshes", async () => { @@ -196,7 +180,7 @@ describe("providers snapshot hook cache scope", () => { expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({}); }); - it("sends cwd for workspace snapshot loads and refreshes", async () => { + it("does not send cwd for repeated snapshot loads and refreshes", async () => { enableProvidersSnapshot(); mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([])); mockClient.refreshProvidersSnapshot.mockResolvedValue({ @@ -204,21 +188,30 @@ describe("providers snapshot hook cache scope", () => { requestId: "workspace-refresh", }); - const { result } = renderProvidersSnapshotHook("/repo"); + const { result } = renderProvidersSnapshotHook(); await waitFor(() => { - expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo" }); + expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({}); }); await act(async () => { await result.current.refresh(["codex"]); }); - expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ - cwd: "/repo", - providers: ["codex"], - }); - expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" }); + expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] }); + expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({}); + }); + + it("applies provider snapshot updates from other cwd values to the global cache", async () => { + enableProvidersSnapshot(); + mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([])); + + const { result } = renderProvidersSnapshotHook(); + + await waitForSnapshotEntries(result, []); + await emitProvidersSnapshotUpdate([codexEntry("ready", [readyCodexModel])], "/repo-b"); + + await waitForSnapshotEntries(result, [codexEntry("ready", [readyCodexModel])]); }); it("refetches loading snapshot updates through the read path but ignores empty updates", async () => { @@ -227,7 +220,7 @@ describe("providers snapshot hook cache scope", () => { .mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [])])) .mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])])); - renderProvidersSnapshotHook("/repo"); + renderProvidersSnapshotHook(); await waitForSnapshotReads(1); await emitProvidersSnapshotUpdate([]); @@ -237,7 +230,7 @@ describe("providers snapshot hook cache scope", () => { await emitProvidersSnapshotUpdate([codexEntry("loading")]); await waitForSnapshotReads(2); - expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" }); + expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({}); expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled(); }); @@ -252,13 +245,13 @@ describe("providers snapshot hook cache scope", () => { .mockResolvedValueOnce(providersSnapshot(entries)) .mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])])); - const { result } = renderProvidersSnapshotHook("/repo"); + const { result } = renderProvidersSnapshotHook(); await waitForSnapshotEntries(result, entries); await openSelectorForSelectedProvider(result); await waitForSnapshotReads(2); - expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" }); + expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({}); expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled(); }); @@ -266,7 +259,7 @@ describe("providers snapshot hook cache scope", () => { enableProvidersSnapshot(); mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])])); - const { result } = renderProvidersSnapshotHook("/repo"); + const { result } = renderProvidersSnapshotHook(); await waitForSnapshotEntries(result, [codexEntry("ready", [])]); await openSelectorForSelectedProvider(result); diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index a1d34a244..8c04f3927 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -6,40 +6,8 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host- import { useSessionStore } from "@/stores/session-store"; import { queryClient as singletonQueryClient } from "@/query/query-client"; -export function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null { - const trimmed = cwd?.trim(); - if (!trimmed) { - return null; - } - - return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~"); -} - -export function shouldApplyProvidersSnapshotUpdate( - currentCwd?: string | null, - messageCwd?: string | null, -): boolean { - const currentCwdKey = normalizeProvidersSnapshotCwdKey(currentCwd); - const messageCwdKey = normalizeProvidersSnapshotCwdKey(messageCwd); - return messageCwdKey === currentCwdKey || (currentCwdKey === null && messageCwdKey === "~"); -} - -export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) { - return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const; -} - -function providersSnapshotRequest(cwd?: string): { cwd?: string } { - return cwd ? { cwd } : {}; -} - -function refreshProvidersSnapshotRequest( - cwd: string | undefined, - providers: AgentProvider[] | undefined, -): { cwd?: string; providers?: AgentProvider[] } { - return { - ...providersSnapshotRequest(cwd), - ...(providers ? { providers } : {}), - }; +export function providersSnapshotQueryKey(serverId: string | null) { + return ["providersSnapshot", serverId] as const; } interface UseProvidersSnapshotResult { @@ -59,23 +27,17 @@ interface UseProvidersSnapshotOptions { export function useProvidersSnapshot( serverId: string | null, - cwd?: string | null, options: UseProvidersSnapshotOptions = {}, ): UseProvidersSnapshotResult { const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const enabled = options.enabled ?? true; - const normalizedCwd = cwd?.trim() || undefined; - const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd); const supportsSnapshot = useSessionStore( (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true, ); - const queryKey = useMemo( - () => providersSnapshotQueryKey(serverId, normalizedCwdKey), - [normalizedCwdKey, serverId], - ); + const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]); const snapshotQuery = useQuery({ queryKey, @@ -85,7 +47,7 @@ export function useProvidersSnapshot( if (!client) { throw new Error("Host is not connected"); } - return client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)); + return client.getProvidersSnapshot({}); }, }); @@ -94,9 +56,7 @@ export function useProvidersSnapshot( if (!client) { return; } - await client.refreshProvidersSnapshot( - refreshProvidersSnapshotRequest(normalizedCwd, providers), - ); + await client.refreshProvidersSnapshot(providers ? { providers } : {}); }, }); const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation; @@ -110,9 +70,6 @@ export function useProvidersSnapshot( if (message.type !== "providers_snapshot_update") { return; } - if (!shouldApplyProvidersSnapshotUpdate(normalizedCwd, message.payload.cwd)) { - return; - } queryClient.setQueryData(queryKey, { entries: message.payload.entries, generatedAt: message.payload.generatedAt, @@ -127,17 +84,7 @@ export function useProvidersSnapshot( }); } }); - }, [ - client, - enabled, - isConnected, - normalizedCwd, - normalizedCwdKey, - queryClient, - queryKey, - serverId, - supportsSnapshot, - ]); + }, [client, enabled, isConnected, queryClient, queryKey, serverId, supportsSnapshot]); const refresh = useCallback( async (providers?: AgentProvider[]) => { @@ -145,10 +92,10 @@ export function useProvidersSnapshot( return; } await refreshSnapshot(providers); - const snapshot = await client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)); + const snapshot = await client.getProvidersSnapshot({}); queryClient.setQueryData(queryKey, snapshot); }, - [client, normalizedCwd, queryClient, queryKey, refreshSnapshot], + [client, queryClient, queryKey, refreshSnapshot], ); const refetchIfStale = useCallback( @@ -184,16 +131,11 @@ export function useProvidersSnapshot( }; } -export function prefetchProvidersSnapshot( - serverId: string, - client: DaemonClient, - cwd?: string | null, -): void { - const normalizedCwd = cwd?.trim() || undefined; - const queryKey = providersSnapshotQueryKey(serverId, normalizedCwd); +export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void { + const queryKey = providersSnapshotQueryKey(serverId); void singletonQueryClient.prefetchQuery({ queryKey, staleTime: 60_000, - queryFn: () => client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)), + queryFn: () => client.getProvidersSnapshot({}), }); } diff --git a/packages/app/src/panels/agent-panel.test.tsx b/packages/app/src/panels/agent-panel.test.tsx index db2d5666d..e4c024445 100644 --- a/packages/app/src/panels/agent-panel.test.tsx +++ b/packages/app/src/panels/agent-panel.test.tsx @@ -43,6 +43,7 @@ const { streamRenderCount, latestStreamPermissionKeys, latestStreamText, + runtimeIsConnected, theme, runtimeClient, } = vi.hoisted(() => { @@ -71,6 +72,7 @@ const { streamRenderCount: vi.fn(), latestStreamPermissionKeys: { current: [] as string[] }, latestStreamText: { current: null as string | null }, + runtimeIsConnected: { current: false }, theme: { colors: { foreground: "#ffffff", @@ -121,8 +123,8 @@ function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles) vi.mock("@/runtime/host-runtime", () => ({ useHosts: () => [{ serverId: "server", label: "Test server" }], useHostRuntimeClient: () => runtimeClient, - useHostRuntimeIsConnected: () => false, - useHostRuntimeConnectionStatus: () => "offline", + useHostRuntimeIsConnected: () => runtimeIsConnected.current, + useHostRuntimeConnectionStatus: () => (runtimeIsConnected.current ? "online" : "offline"), useHostRuntimeLastError: () => null, })); @@ -240,6 +242,51 @@ function makeAgent(overrides: Partial = {}): Agent { }; } +function makeFetchedAgentResult(agent: Agent): Awaited> { + return { + project: { + projectKey: agent.cwd, + projectName: "workspace", + checkout: { + cwd: agent.cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + agent: { + id: agent.id, + provider: agent.provider, + status: agent.status, + createdAt: agent.createdAt.toISOString(), + updatedAt: agent.updatedAt.toISOString(), + lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, + runtimeInfo: agent.runtimeInfo ?? { + provider: agent.provider, + sessionId: null, + }, + capabilities: agent.capabilities, + currentModeId: agent.currentModeId, + availableModes: agent.availableModes, + pendingPermissions: agent.pendingPermissions, + persistence: agent.persistence, + title: agent.title, + cwd: agent.cwd, + model: agent.model, + thinkingOptionId: agent.thinkingOptionId ?? null, + requiresAttention: agent.requiresAttention ?? false, + attentionReason: agent.attentionReason ?? null, + attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null, + archivedAt: agent.archivedAt?.toISOString() ?? null, + labels: agent.labels, + lastError: agent.lastError ?? undefined, + }, + }; +} + function seedReadyAgent(agent: Agent = makeAgent()) { const store = useSessionStore.getState(); store.initializeSession("server", makeClient()); @@ -367,6 +414,9 @@ describe("AgentPanel render isolation", () => { streamRenderCount.mockClear(); latestStreamPermissionKeys.current = []; latestStreamText.current = null; + runtimeIsConnected.current = false; + runtimeClient.fetchAgent.mockReset(); + runtimeClient.fetchAgentTimeline.mockReset(); }); it("refreshes the stream view without invoking Composer for stream-only updates", async () => { @@ -463,4 +513,49 @@ describe("AgentPanel render isolation", () => { expect(latestStreamPermissionKeys.current).toEqual([]); expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 2); }); + + it("renders an archived lazy detail without adding it to the active agent store", async () => { + const archivedAgent = makeAgent({ + archivedAt: new Date("2026-04-20T00:00:02.000Z"), + }); + runtimeIsConnected.current = true; + runtimeClient.fetchAgent.mockResolvedValue(makeFetchedAgentResult(archivedAgent)); + runtimeClient.fetchAgentTimeline.mockResolvedValue({ + agent: null, + events: [], + nextCursor: null, + hasMore: false, + }); + useSessionStore + .getState() + .initializeSession("server", runtimeClient as unknown as DaemonClient); + useSessionStore.getState().setAgentAuthoritativeHistoryApplied("server", "agent", true); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await renderAgentPanel(root, { + isWorkspaceFocused: true, + isPaneFocused: true, + isInteractive: true, + }); + + const timeoutAt = Date.now() + 300; + while ( + !container?.querySelector('[data-testid="archived-agent-callout"]') && + Date.now() < timeoutAt + ) { + await act(async () => { + await Promise.resolve(); + }); + } + + expect(container?.querySelector('[data-testid="archived-agent-callout"]')).not.toBeNull(); + + expect(useSessionStore.getState().sessions.server?.agents.has("agent")).toBe(false); + expect( + useSessionStore.getState().sessions.server?.agentDetails.get("agent")?.archivedAt, + ).toEqual(archivedAgent.archivedAt); + }); }); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 3584199cf..abaaf4901 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,53 +1,54 @@ +import type { DaemonClient } from "@server/client/daemon-client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Text, View } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import invariant from "tiny-invariant"; import { shallow, useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import invariant from "tiny-invariant"; import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; -import { Composer } from "@/components/composer"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; +import { Composer } from "@/components/composer"; import { FileDropZone } from "@/components/file-drop-zone"; -import { getProviderIcon } from "@/components/provider-icons"; import type { ImageAttachment } from "@/components/message-input"; +import { getProviderIcon } from "@/components/provider-icons"; import { ToastViewport, useToastHost } from "@/components/toast-host"; +import { isNative } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; +import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { - useAgentScreenStateMachine, type AgentScreenAgent, type AgentScreenMissingState, + useAgentScreenStateMachine, } from "@/hooks/use-agent-screen-state-machine"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useStableEvent } from "@/hooks/use-stable-event"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { + type HostRuntimeConnectionStatus, useHostRuntimeClient, useHostRuntimeConnectionStatus, useHostRuntimeIsConnected, useHostRuntimeLastError, useHosts, - type HostRuntimeConnectionStatus, } from "@/runtime/host-runtime"; -import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; -import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; -import { mergePendingCreateImages } from "@/utils/pending-create-images"; -import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { buildDraftStoreKey } from "@/stores/draft-keys"; -import { useSessionStore, type Agent } from "@/stores/session-store"; -import type { PendingPermission } from "@/types/shared"; -import type { StreamItem } from "@/types/stream"; import { deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorRequest, } from "@/screens/agent/agent-ready-screen-bottom-anchor"; -import { isNative } from "@/constants/platform"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { type Agent, useSessionStore } from "@/stores/session-store"; +import type { PendingPermission } from "@/types/shared"; +import type { StreamItem } from "@/types/stream"; +import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; +import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { mergePendingCreateImages } from "@/utils/pending-create-images"; +import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; function formatProviderLabel(provider: Agent["provider"]): string { if (!provider) { @@ -74,13 +75,63 @@ function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string return normalized; } +function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean { + return !agent.archivedAt && Boolean(agent.projectPlacement); +} + +type FetchAgentResult = Awaited>; + +function storeFetchedAgentDetail(input: { + serverId: string; + result: NonNullable; +}): Agent { + const normalized = normalizeAgentSnapshot(input.result.agent, input.serverId); + const hydrated: Agent = { + ...normalized, + projectPlacement: input.result.project, + }; + const store = useSessionStore.getState(); + + if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) { + store.setAgents(input.serverId, (previous) => { + const next = new Map(previous); + next.set(hydrated.id, hydrated); + return next; + }); + } else { + store.setAgentDetails(input.serverId, (previous) => { + const next = new Map(previous); + next.set(hydrated.id, hydrated); + return next; + }); + } + + store.setPendingPermissions(input.serverId, (previous) => { + const next = new Map(previous); + for (const [key, pending] of next.entries()) { + if (pending.agentId === hydrated.id) { + next.delete(key); + } + } + for (const request of hydrated.pendingPermissions) { + const key = derivePendingPermissionKey(hydrated.id, request); + next.set(key, { key, agentId: hydrated.id, request }); + } + return next; + }); + + return hydrated; +} + function useAgentPanelDescriptor( target: { kind: "agent"; agentId: string }, context: { serverId: string }, ): PanelDescriptor { const descriptorState = useSessionStore( useShallow((state) => { - const agent = state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null; + const session = state.sessions[context.serverId]; + const agent = + session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null; return { provider: agent?.provider ?? "codex", title: agent?.title ?? null, @@ -262,17 +313,27 @@ function AgentPanelBody({ const { theme } = useUnistyles(); const { isArchivingAgent } = useArchiveAgent(); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); - const setAgents = useSessionStore((state) => state.setAgents); - const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const projectPlacement = useStoreWithEqualityFn( useSessionStore, - (state) => - agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null, + (state) => { + if (!agentId) { + return null; + } + const session = state.sessions[serverId]; + return ( + session?.agents?.get(agentId)?.projectPlacement ?? + session?.agentDetails?.get(agentId)?.projectPlacement ?? + null + ); + }, (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), ); const agentState = useSessionStore( useShallow((state) => { - const agent = agentId ? (state.sessions[serverId]?.agents?.get(agentId) ?? null) : null; + const session = state.sessions[serverId]; + const agent = agentId + ? (session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null) + : null; return { serverId: agent?.serverId ?? null, id: agent?.id ?? null, @@ -325,29 +386,7 @@ function AgentPanelBody({ return; } - const normalized = normalizeAgentSnapshot(result.agent, serverId); - const hydrated = { - ...normalized, - projectPlacement: result.project, - }; - setAgents(serverId, (previous) => { - const next = new Map(previous); - next.set(hydrated.id, hydrated); - return next; - }); - setPendingPermissions(serverId, (previous) => { - const next = new Map(previous); - for (const [key, pending] of next.entries()) { - if (pending.agentId === hydrated.id) { - next.delete(key); - } - } - for (const request of hydrated.pendingPermissions) { - const key = derivePendingPermissionKey(hydrated.id, request); - next.set(key, { key, agentId: hydrated.id, request }); - } - return next; - }); + storeFetchedAgentDetail({ serverId, result }); setLookupState({ tag: "idle" }); }) .catch((error) => { @@ -361,17 +400,7 @@ function AgentPanelBody({ } setLookupState({ tag: "error", message }); }); - }, [ - agentId, - agentState.id, - client, - hasSession, - isConnected, - lookupState.tag, - serverId, - setAgents, - setPendingPermissions, - ]); + }, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]); if (lookupState.tag === "not_found") { return ( @@ -471,7 +500,10 @@ function ChatAgentContent({ const agentState = useSessionStore( useShallow((state) => { - const agent = agentId ? (state.sessions[serverId]?.agents?.get(agentId) ?? null) : null; + const session = state.sessions[serverId]; + const agent = agentId + ? (session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null) + : null; return { serverId: agent?.serverId ?? null, id: agent?.id ?? null, @@ -486,8 +518,17 @@ function ChatAgentContent({ ); const projectPlacement = useStoreWithEqualityFn( useSessionStore, - (state) => - agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null, + (state) => { + if (!agentId) { + return null; + } + const session = state.sessions[serverId]; + return ( + session?.agents?.get(agentId)?.projectPlacement ?? + session?.agentDetails?.get(agentId)?.projectPlacement ?? + null + ); + }, (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), ); const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); @@ -505,8 +546,6 @@ function ChatAgentContent({ const agentHistorySyncGeneration = useSessionStore((state) => agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1, ); - const setAgents = useSessionStore((state) => state.setAgents); - const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const { ensureAgentIsInitialized } = useAgentInitialization({ serverId, @@ -776,7 +815,9 @@ function ChatAgentContent({ if (attemptToken !== initAttemptTokenRef.current) { return; } - const currentAgent = useSessionStore.getState().sessions[serverId]?.agents.get(agentId); + const currentSession = useSessionStore.getState().sessions[serverId]; + const currentAgent = + currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId); if (!currentAgent) { const result = await client.fetchAgent(agentId); if (attemptToken !== initAttemptTokenRef.current) { @@ -789,29 +830,7 @@ function ChatAgentContent({ }); return; } - const normalized = normalizeAgentSnapshot(result.agent, serverId); - const hydrated = { - ...normalized, - projectPlacement: result.project, - }; - setAgents(serverId, (previous) => { - const next = new Map(previous); - next.set(hydrated.id, hydrated); - return next; - }); - setPendingPermissions(serverId, (previous) => { - const next = new Map(previous); - for (const [key, pending] of next.entries()) { - if (pending.agentId === hydrated.id) { - next.delete(key); - } - } - for (const request of hydrated.pendingPermissions) { - const key = derivePendingPermissionKey(hydrated.id, request); - next.set(key, { key, agentId: hydrated.id, request }); - } - return next; - }); + storeFetchedAgentDetail({ serverId, result }); } if (attemptToken !== initAttemptTokenRef.current) { return; @@ -838,8 +857,6 @@ function ChatAgentContent({ isConnected, missingAgentState.kind, serverId, - setAgents, - setPendingPermissions, shouldUseOptimisticStream, ]); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index dab40f358..f8333610f 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1112,7 +1112,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ - filter: { includeArchived: true }, + scope: "active", sort: [{ key: "updated_at", direction: "desc" }], subscribe: { subscriptionId: "app:srv_test" }, page: { limit: 200 }, @@ -1160,7 +1160,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ - filter: { includeArchived: true }, + scope: "active", sort: [{ key: "updated_at", direction: "desc" }], subscribe: { subscriptionId: "app:srv_no_session" }, page: { limit: 200 }, @@ -1169,7 +1169,7 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); - it("fetches all pages during bootstrap so older workspace agents are present", async () => { + it("fetches all pages during bootstrap within the active agent scope", async () => { const host = makeHost({ serverId: "srv_paged", connections: [ @@ -1234,13 +1234,13 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls).toHaveLength(2); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ - filter: { includeArchived: true }, + scope: "active", sort: [{ key: "updated_at", direction: "desc" }], subscribe: { subscriptionId: "app:srv_paged" }, page: { limit: 200 }, }); expect(fakeClient.fetchAgentsCalls[1]).toEqual({ - filter: { includeArchived: true }, + scope: "active", sort: [{ key: "updated_at", direction: "desc" }], page: { limit: 200, cursor: "cursor-page-2" }, }); @@ -1314,13 +1314,13 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls).toEqual([ { - filter: { includeArchived: true }, + scope: "active", sort: [{ key: "updated_at", direction: "desc" }], subscribe: { subscriptionId: "app:srv_resubscribe" }, page: { limit: 200 }, }, { - filter: { includeArchived: true }, + scope: "active", sort: [{ key: "updated_at", direction: "desc" }], subscribe: { subscriptionId: "app:srv_resubscribe" }, page: { limit: 200 }, @@ -1331,7 +1331,7 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); - it("rehydrates archived agents over stale active session state after reconnect bootstrap", async () => { + it("replaces stale active session state when active bootstrap omits an agent", async () => { const host = makeHost({ serverId: "srv_archived_rehydrate", connections: [ @@ -1346,15 +1346,7 @@ describe("HostRuntimeStore", () => { fakeClient.setConnectionState({ status: "connected" }); fakeClient.fetchAgentsResponses.push( makeFetchAgentsPayload({ - entries: [ - makeFetchAgentsEntry({ - id: "agent-archived", - cwd: "/Users/moboudra/dev/paseo", - updatedAt: "2026-03-30T15:30:00.000Z", - archivedAt: "2026-03-30T15:31:00.000Z", - title: "Archived remotely", - }), - ], + entries: [], subscriptionId: "app:srv_archived_rehydrate", }), ); @@ -1397,17 +1389,16 @@ describe("HostRuntimeStore", () => { store.syncHosts([host]); const timeoutAt = Date.now() + 300; - let archivedAt = - useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived") - ?.archivedAt ?? null; - while (!archivedAt && Date.now() < timeoutAt) { + while ( + useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived") && + Date.now() < timeoutAt + ) { await new Promise((resolve) => setTimeout(resolve, 0)); - archivedAt = - useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived") - ?.archivedAt ?? null; } - expect(archivedAt?.toISOString()).toBe("2026-03-30T15:31:00.000Z"); + expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived")).toBe( + false, + ); store.syncHosts([]); useSessionStore.getState().clearSession(host.serverId); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index e02390952..bc8e179e1 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -4,6 +4,7 @@ import equal from "fast-deep-equal/es6"; import { DaemonClient, type ConnectionState, + type FetchAgentsEntry, type FetchAgentsOptions, } from "@server/client/daemon-client"; import { @@ -31,8 +32,8 @@ import { createDesktopLocalDaemonTransportFactory, } from "@/desktop/daemon/desktop-daemon-transport"; import { isDev } from "@/constants/platform"; -import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; -import { useSessionStore, type Agent } from "@/stores/session-store"; +import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync"; +import { useSessionStore } from "@/stores/session-store"; export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; @@ -1759,7 +1760,7 @@ export class HostRuntimeStore { subscribe?: FetchAgentsOptions["subscribe"]; page?: FetchAgentsOptions["page"]; }): Promise<{ - agents: ReturnType["agents"]; + agents: ReturnType["agents"]; subscriptionId: string | null; }> { const controller = this.controllers.get(input.serverId); @@ -1778,23 +1779,18 @@ export class HostRuntimeStore { let cursor = input.page?.cursor ?? null; let includeSubscribe = true; let subscriptionId: string | null = null; - const allAgents = new Map(); + const allEntries: FetchAgentsEntry[] = []; while (true) { const payload = await client.fetchAgents({ - filter: input.filter ?? { includeArchived: true }, + 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 }, }); - const pageAgents = applyFetchedAgentDirectory({ - serverId: input.serverId, - entries: payload.entries, - }).agents; - for (const [agentId, agent] of pageAgents) { - allAgents.set(agentId, agent); - } + allEntries.push(...payload.entries); subscriptionId = subscriptionId ?? payload.subscriptionId ?? null; includeSubscribe = false; @@ -1810,9 +1806,14 @@ export class HostRuntimeStore { cursor = nextCursor; } + const { agents } = replaceFetchedAgentDirectory({ + serverId: input.serverId, + entries: allEntries, + }); + controller.markAgentDirectorySyncReady(); return { - agents: allAgents, + agents, subscriptionId, }; } catch (error) { diff --git a/packages/app/src/screens/sessions-screen.test.tsx b/packages/app/src/screens/sessions-screen.test.tsx new file mode 100644 index 000000000..1f6b1a22f --- /dev/null +++ b/packages/app/src/screens/sessions-screen.test.tsx @@ -0,0 +1,158 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentHistoryResult } from "@/hooks/use-agent-history"; +import { SessionsScreen } from "@/screens/sessions-screen"; + +const { historyResult, navigate } = vi.hoisted(() => ({ + historyResult: { + current: null as AgentHistoryResult | null, + }, + navigate: vi.fn(), +})); + +vi.mock("react-native", () => ({ + View: ({ children, ...props }: React.PropsWithChildren>) => + React.createElement("div", props, children), + Text: ({ children, ...props }: React.PropsWithChildren>) => + React.createElement("span", props, children), +})); + +vi.mock("react-native-unistyles", () => { + const theme = { + spacing: { 4: 16, 6: 24 }, + fontSize: { lg: 18 }, + colors: { + surface0: "#111", + foregroundMuted: "#999", + }, + }; + + return { + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme }), + }; +}); + +vi.mock("@react-navigation/native", () => ({ + useIsFocused: () => true, +})); + +vi.mock("expo-router", () => ({ + router: { + navigate, + }, +})); + +vi.mock("lucide-react-native", () => ({ + ChevronLeft: () => React.createElement("span", { "data-icon": "ChevronLeft" }), +})); + +vi.mock("@/components/headers/menu-header", () => ({ + MenuHeader: ({ title }: { title: string }) => + React.createElement("header", { "data-testid": "menu-header" }, title), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, onPress }: React.PropsWithChildren<{ onPress?: () => void }>) => + React.createElement("button", { onClick: onPress }, children), +})); + +vi.mock("@/components/ui/loading-spinner", () => ({ + LoadingSpinner: ({ color, size }: { color: string; size?: string }) => + React.createElement("div", { + "data-color": color, + "data-size": size, + "data-testid": "sessions-loading-spinner", + }), +})); + +vi.mock("@/components/agent-list", () => ({ + AgentList: ({ agents }: { agents: unknown[] }) => + React.createElement("div", { "data-agent-count": agents.length, "data-testid": "agent-list" }), +})); + +vi.mock("@/hooks/use-agent-history", () => ({ + useAgentHistory: () => { + if (!historyResult.current) { + throw new Error("Expected history result"); + } + return historyResult.current; + }, +})); + +vi.mock("@/utils/host-routes", () => ({ + buildHostOpenProjectRoute: (serverId: string) => `/h/${serverId}/open-project`, +})); + +function makeHistoryResult(overrides: Partial = {}): AgentHistoryResult { + return { + agents: [], + isLoading: false, + isInitialLoad: false, + isRevalidating: false, + hasMore: false, + isLoadingMore: false, + refreshAll: vi.fn(), + loadMore: vi.fn(), + ...overrides, + }; +} + +describe("SessionsScreen", () => { + let container: HTMLElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + navigate.mockReset(); + historyResult.current = makeHistoryResult(); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + historyResult.current = null; + vi.unstubAllGlobals(); + }); + + it("shows the shared loader during the initial React Query history load", () => { + historyResult.current = makeHistoryResult({ isInitialLoad: true, isLoading: true }); + + act(() => { + root?.render(); + }); + + expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).not.toBeNull(); + expect(container?.textContent).not.toContain("No sessions yet"); + expect(container?.textContent).not.toContain("Back"); + }); + + it("shows the empty state after history finishes loading with no sessions", () => { + historyResult.current = makeHistoryResult(); + + act(() => { + root?.render(); + }); + + expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).toBeNull(); + expect(container?.textContent).toContain("No sessions yet"); + expect(container?.textContent).toContain("Back"); + }); +}); diff --git a/packages/app/src/screens/sessions-screen.tsx b/packages/app/src/screens/sessions-screen.tsx index 6e8934683..6216d553e 100644 --- a/packages/app/src/screens/sessions-screen.tsx +++ b/packages/app/src/screens/sessions-screen.tsx @@ -2,12 +2,13 @@ import { useMemo, useState, useCallback, useEffect } from "react"; import { View, Text } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import { router } from "expo-router"; -import { StyleSheet } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronLeft } from "lucide-react-native"; import { MenuHeader } from "@/components/headers/menu-header"; import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { AgentList } from "@/components/agent-list"; -import { useAllAgentsList } from "@/hooks/use-all-agents-list"; +import { useAgentHistory } from "@/hooks/use-agent-history"; import { buildHostOpenProjectRoute } from "@/utils/host-routes"; export function SessionsScreen({ serverId }: { serverId: string }) { @@ -21,10 +22,11 @@ export function SessionsScreen({ serverId }: { serverId: string }) { } function SessionsScreenContent({ serverId }: { serverId: string }) { - const { agents, isRevalidating, refreshAll } = useAllAgentsList({ - serverId, - includeArchived: true, - }); + const { theme } = useUnistyles(); + const { agents, hasMore, isInitialLoad, isLoadingMore, isRevalidating, loadMore, refreshAll } = + useAgentHistory({ + serverId, + }); // Track user-initiated refresh to avoid showing spinner on background revalidation const [isManualRefresh, setIsManualRefresh] = useState(false); @@ -48,7 +50,11 @@ function SessionsScreenContent({ serverId }: { serverId: string }) { return ( - {sortedAgents.length === 0 ? ( + {isInitialLoad ? ( + + + + ) : sortedAgents.length === 0 ? ( No sessions yet + + ) : null + } showAttentionIndicator={false} /> )} @@ -88,4 +103,13 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.lg, }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + footer: { + alignItems: "center", + paddingVertical: theme.spacing[4], + }, })); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts index 4cc0ad96e..cda77286f 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -88,6 +88,25 @@ describe("workspace agent visibility", () => { expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false); }); + it("treats lazy historical details as known without making them active", () => { + const workspaceDirectory = "/repo/worktree"; + const active = makeAgent({ id: "active-agent", cwd: workspaceDirectory }); + const historicalDetail = makeAgent({ + id: "historical-agent", + cwd: workspaceDirectory, + archivedAt: new Date("2026-03-04T00:01:00.000Z"), + }); + + const result = deriveWorkspaceAgentVisibility({ + sessionAgents: new Map([[active.id, active]]), + agentDetails: new Map([[historicalDetail.id, historicalDetail]]), + workspaceDirectory, + }); + + expect(result.activeAgentIds).toEqual(new Set(["active-agent"])); + expect(result.knownAgentIds).toEqual(new Set(["active-agent", "historical-agent"])); + }); + it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => { const knownAgentIds = new Set(["archived-agent"]); const activeAgentIds = new Set(); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts index 949b54bda..1707d633e 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -12,11 +12,12 @@ export interface WorkspaceAgentVisibility { export function deriveWorkspaceAgentVisibility(input: { sessionAgents: Map | undefined; + agentDetails?: Map | undefined; workspaceDirectory: string | null | undefined; }): WorkspaceAgentVisibility { - const { sessionAgents, workspaceDirectory } = input; + const { sessionAgents, agentDetails, workspaceDirectory } = input; const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory); - if (!sessionAgents || !normalizedWorkspaceDirectory) { + if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) { return { activeAgentIds: new Set(), knownAgentIds: new Set(), @@ -25,7 +26,7 @@ export function deriveWorkspaceAgentVisibility(input: { const activeAgentIds = new Set(); const knownAgentIds = new Set(); - for (const agent of sessionAgents.values()) { + for (const agent of sessionAgents?.values() ?? []) { if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) { continue; } @@ -34,6 +35,12 @@ export function deriveWorkspaceAgentVisibility(input: { activeAgentIds.add(agent.id); } } + for (const agent of agentDetails?.values() ?? []) { + if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) { + continue; + } + knownAgentIds.add(agent.id); + } return { activeAgentIds, knownAgentIds }; } diff --git a/packages/app/src/screens/workspace/workspace-header-source.ts b/packages/app/src/screens/workspace/workspace-header-source.ts index 3c9029110..3df64733a 100644 --- a/packages/app/src/screens/workspace/workspace-header-source.ts +++ b/packages/app/src/screens/workspace/workspace-header-source.ts @@ -1,5 +1,41 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; +export type WorkspaceHeaderCheckoutState = + | { kind: "pending" } + | { kind: "error" } + | { kind: "ready"; checkout: { isGit: boolean; currentBranch: string | null } }; + +type WorkspaceHeaderRenderState = + | { kind: "skeleton" } + | { + kind: "ready"; + title: string; + subtitle: string; + shouldShowSubtitle: boolean; + isGitCheckout: boolean; + currentBranchName: string | null; + }; + +function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function areHeaderLabelsEquivalent( + a: string | null | undefined, + b: string | null | undefined, +): boolean { + const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase(); + const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase(); + if (!normalizedA || !normalizedB) { + return false; + } + return normalizedA === normalizedB; +} + export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }): { title: string; subtitle: string; @@ -10,6 +46,35 @@ export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor } }; } +export function resolveWorkspaceHeaderRenderState(input: { + workspace: WorkspaceDescriptor | null; + checkoutState: WorkspaceHeaderCheckoutState; +}): WorkspaceHeaderRenderState { + if (!input.workspace) { + return { kind: "skeleton" }; + } + + if (input.checkoutState.kind === "pending" && input.workspace.projectKind === "git") { + return { kind: "skeleton" }; + } + + const header = resolveWorkspaceHeader({ workspace: input.workspace }); + const checkout = input.checkoutState.kind === "ready" ? input.checkoutState.checkout : null; + const currentBranchName = + checkout?.isGit && checkout.currentBranch !== "HEAD" + ? trimNonEmpty(checkout.currentBranch) + : null; + + return { + kind: "ready", + title: header.title, + subtitle: header.subtitle, + shouldShowSubtitle: !areHeaderLabelsEquivalent(header.title, header.subtitle), + isGitCheckout: checkout?.isGit ?? false, + currentBranchName, + }; +} + export function shouldRenderMissingWorkspaceDescriptor(input: { workspace: WorkspaceDescriptor | null; hasHydratedWorkspaces: boolean; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 305b0f4e5..9f9c865dd 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -96,8 +96,9 @@ import { import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { - resolveWorkspaceHeader, + resolveWorkspaceHeaderRenderState, shouldRenderMissingWorkspaceDescriptor, + type WorkspaceHeaderCheckoutState, } from "@/screens/workspace/workspace-header-source"; import { deriveWorkspaceAgentVisibility, @@ -153,18 +154,6 @@ function decodeSegment(value: string): string { } } -function areHeaderLabelsEquivalent( - a: string | null | undefined, - b: string | null | undefined, -): boolean { - const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase(); - const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase(); - if (!normalizedA || !normalizedB) { - return false; - } - return normalizedA === normalizedB; -} - function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return "New Agent"; @@ -658,9 +647,8 @@ function WorkspaceScreenContent({ workspaceDescriptor && !workspaceExecutionAuthority, ); - // Warm the server-side provider snapshot for this workspace cwd so the model - // picker is ready when opened. Consumers share the same query cache key. - useProvidersSnapshot(normalizedServerId, workspaceDirectory, { + // Warm the global provider snapshot so the model picker is ready when opened. + useProvidersSnapshot(normalizedServerId, { enabled: isRouteFocused, }); const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{ @@ -675,6 +663,7 @@ function WorkspaceScreenContent({ (state) => deriveWorkspaceAgentVisibility({ sessionAgents: state.sessions[normalizedServerId]?.agents, + agentDetails: state.sessions[normalizedServerId]?.agentDetails, workspaceDirectory, }), workspaceAgentVisibilityEqual, @@ -879,21 +868,36 @@ function WorkspaceScreenContent({ pendingTerminalCreateInput, toast, ]); - const workspaceHeader = workspaceDescriptor - ? resolveWorkspaceHeader({ workspace: workspaceDescriptor }) - : null; - const isWorkspaceHeaderLoading = workspaceHeader === null || isCheckoutStatusLoading; - const workspaceHeaderTitle = workspaceHeader?.title ?? ""; - const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? ""; - const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent( - workspaceHeaderTitle, - workspaceHeaderSubtitle, - ); - - const isGitCheckout = checkoutQuery.data?.isGit ?? false; + let workspaceHeaderCheckoutState: WorkspaceHeaderCheckoutState; + if (isCheckoutStatusLoading) { + workspaceHeaderCheckoutState = { kind: "pending" }; + } else if (checkoutQuery.isError || !checkoutQuery.data) { + workspaceHeaderCheckoutState = { kind: "error" }; + } else { + workspaceHeaderCheckoutState = { + kind: "ready", + checkout: { + isGit: checkoutQuery.data.isGit, + currentBranch: checkoutQuery.data.currentBranch, + }, + }; + } + const workspaceHeaderRenderState = resolveWorkspaceHeaderRenderState({ + workspace: workspaceDescriptor, + checkoutState: workspaceHeaderCheckoutState, + }); + const isWorkspaceHeaderLoading = workspaceHeaderRenderState.kind === "skeleton"; + const workspaceHeaderTitle = + workspaceHeaderRenderState.kind === "ready" ? workspaceHeaderRenderState.title : ""; + const workspaceHeaderSubtitle = + workspaceHeaderRenderState.kind === "ready" ? workspaceHeaderRenderState.subtitle : ""; + const shouldShowWorkspaceHeaderSubtitle = + workspaceHeaderRenderState.kind === "ready" && workspaceHeaderRenderState.shouldShowSubtitle; + const isGitCheckout = + workspaceHeaderRenderState.kind === "ready" ? workspaceHeaderRenderState.isGitCheckout : false; const currentBranchName = - checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD" - ? trimNonEmpty(checkoutQuery.data.currentBranch) + workspaceHeaderRenderState.kind === "ready" + ? workspaceHeaderRenderState.currentBranchName : null; const isExplorerOpen = usePanelStore((state) => diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index 8c6101b40..90e870c96 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -6,26 +6,32 @@ vi.hoisted(() => { import { resolveWorkspaceHeader, + resolveWorkspaceHeaderRenderState, shouldRenderMissingWorkspaceDescriptor, } from "./workspace-header-source"; import { createSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { WorkspaceDescriptor } from "@/stores/session-store"; +function createWorkspaceDescriptor(input: Partial = {}): WorkspaceDescriptor { + return { + id: "/repo/main", + projectId: "remote:github.com/getpaseo/paseo", + projectDisplayName: "getpaseo/paseo", + projectRootPath: "/repo/main", + workspaceDirectory: "/repo/main", + projectKind: "git", + workspaceKind: "local_checkout", + name: "feat/workspace-sot", + status: "running", + diffStat: null, + scripts: [], + ...input, + }; +} + describe("workspace source of truth consumption", () => { it("uses the same descriptor name in header and sidebar row", () => { - const workspace: WorkspaceDescriptor = { - id: "/repo/main", - projectId: "remote:github.com/getpaseo/paseo", - projectDisplayName: "getpaseo/paseo", - projectRootPath: "/repo/main", - workspaceDirectory: "/repo/main", - projectKind: "git", - workspaceKind: "checkout", - name: "feat/workspace-sot", - status: "running", - diffStat: null, - scripts: [], - }; + const workspace = createWorkspaceDescriptor(); const header = resolveWorkspaceHeader({ workspace }); const sidebarWorkspace = createSidebarWorkspaceEntry({ @@ -39,6 +45,104 @@ describe("workspace source of truth consumption", () => { expect(sidebarWorkspace.statusBucket).toBe("running"); }); + it("keeps the header skeleton while the workspace descriptor is missing", () => { + expect( + resolveWorkspaceHeaderRenderState({ + workspace: null, + checkoutState: { kind: "pending" }, + }), + ).toEqual({ kind: "skeleton" }); + }); + + it("keeps git workspace headers skeletoned until checkout status resolves", () => { + expect( + resolveWorkspaceHeaderRenderState({ + workspace: createWorkspaceDescriptor({ projectKind: "git" }), + checkoutState: { kind: "pending" }, + }), + ).toEqual({ kind: "skeleton" }); + }); + + it("renders known non-git workspace identity while checkout status is pending", () => { + expect( + resolveWorkspaceHeaderRenderState({ + workspace: createWorkspaceDescriptor({ + projectKind: "non_git", + workspaceKind: "directory", + name: "notes", + projectDisplayName: "Local folders", + }), + checkoutState: { kind: "pending" }, + }), + ).toEqual({ + kind: "ready", + title: "notes", + subtitle: "Local folders", + shouldShowSubtitle: true, + isGitCheckout: false, + currentBranchName: null, + }); + }); + + it("renders git checkout headers with branch affordance after checkout status resolves", () => { + expect( + resolveWorkspaceHeaderRenderState({ + workspace: createWorkspaceDescriptor(), + checkoutState: { + kind: "ready", + checkout: { isGit: true, currentBranch: "feat/workspace-sot" }, + }, + }), + ).toEqual({ + kind: "ready", + title: "feat/workspace-sot", + subtitle: "getpaseo/paseo", + shouldShowSubtitle: true, + isGitCheckout: true, + currentBranchName: "feat/workspace-sot", + }); + }); + + it("renders non-git checkout headers without branch affordance after checkout status resolves", () => { + expect( + resolveWorkspaceHeaderRenderState({ + workspace: createWorkspaceDescriptor({ + projectKind: "non_git", + workspaceKind: "directory", + name: "notes", + projectDisplayName: "notes", + }), + checkoutState: { + kind: "ready", + checkout: { isGit: false, currentBranch: null }, + }, + }), + ).toEqual({ + kind: "ready", + title: "notes", + subtitle: "notes", + shouldShowSubtitle: false, + isGitCheckout: false, + currentBranchName: null, + }); + }); + + it("renders descriptor identity after checkout status errors", () => { + expect( + resolveWorkspaceHeaderRenderState({ + workspace: createWorkspaceDescriptor(), + checkoutState: { kind: "error" }, + }), + ).toEqual({ + kind: "ready", + title: "feat/workspace-sot", + subtitle: "getpaseo/paseo", + shouldShowSubtitle: true, + isGitCheckout: false, + currentBranchName: null, + }); + }); + it("renders explicit missing state only after workspace hydration", () => { expect( shouldRenderMissingWorkspaceDescriptor({ diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 916132da6..8a01d86b3 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -275,6 +275,7 @@ export interface SessionState { // Agents agents: Map; + agentDetails: Map; workspaces: Map; // Permissions @@ -367,6 +368,10 @@ interface SessionStoreActions { serverId: string, agents: Map | ((prev: Map) => Map), ) => void; + setAgentDetails: ( + serverId: string, + agents: Map | ((prev: Map) => Map), + ) => void; setWorkspaces: ( serverId: string, workspaces: @@ -441,6 +446,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess agentAuthoritativeHistoryApplied: new Map(), initializingAgents: new Map(), agents: new Map(), + agentDetails: new Map(), workspaces: new Map(), pendingPermissions: new Map(), fileExplorer: new Map(), @@ -518,10 +524,13 @@ export const useSessionStore = create()( const nextSessions = { ...prev.sessions }; delete nextSessions[serverId]; let nextActivity = prev.agentLastActivity; - if (session.agents.size > 0) { + if (session.agents.size > 0 || session.agentDetails.size > 0) { const candidate = new Map(prev.agentLastActivity); let changed = false; - for (const agentId of session.agents.keys()) { + for (const agentId of new Set([ + ...session.agents.keys(), + ...session.agentDetails.keys(), + ])) { if (candidate.delete(agentId)) { changed = true; } @@ -951,6 +960,26 @@ export const useSessionStore = create()( }); }, + setAgentDetails: (serverId, agents) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + const nextAgents = typeof agents === "function" ? agents(session.agentDetails) : agents; + if (session.agentDetails === nextAgents) { + return prev; + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, agentDetails: nextAgents }, + }, + }; + }); + }, + setWorkspaces: (serverId, workspaces) => { set((prev) => { const session = prev.sessions[serverId]; diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index c660b9540..f9526df46 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -1,8 +1,10 @@ -import type { FetchAgentsEntry } from "@server/client/daemon-client"; -import { useSessionStore, type Agent } from "@/stores/session-store"; +import type { FetchAgentHistoryEntry, FetchAgentsEntry } from "@server/client/daemon-client"; +import { type Agent, useSessionStore } from "@/stores/session-store"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; +type AgentDirectoryFetchEntry = FetchAgentsEntry | FetchAgentHistoryEntry; + type PendingPermissionEntry = { key: string; agentId: string; @@ -11,7 +13,7 @@ type PendingPermissionEntry = { export function buildAgentDirectoryState(input: { serverId: string; - entries: FetchAgentsEntry[]; + entries: AgentDirectoryFetchEntry[]; }): { agents: Map; pendingPermissions: Map; @@ -40,20 +42,24 @@ export function buildAgentDirectoryState(input: { return { agents, pendingPermissions }; } -export function applyFetchedAgentDirectory(input: { +export function replaceFetchedAgentDirectory(input: { serverId: string; entries: FetchAgentsEntry[]; }): { agents: Map } { const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input); - const store = useSessionStore.getState(); - store.setAgents(input.serverId, (prev) => { - const merged = new Map(prev); - for (const [id, agent] of fetchedAgents) { - merged.set(id, agent); + store.setAgents(input.serverId, fetchedAgents); + store.setAgentDetails(input.serverId, (prev) => { + let next: Map | null = null; + for (const agentId of fetchedAgents.keys()) { + if (!prev.has(agentId)) { + continue; + } + next ??= new Map(prev); + next.delete(agentId); } - return merged; + return next ?? prev; }); const lastActivityByAgentId = new Map(); @@ -62,13 +68,7 @@ export function applyFetchedAgentDirectory(input: { } store.setAgentLastActivityBatch(lastActivityByAgentId); - store.setPendingPermissions(input.serverId, (prev) => { - const merged = new Map(prev); - for (const [key, entry] of pendingPermissions) { - merged.set(key, entry); - } - return merged; - }); + 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/server/scripts/dev-runner.ts b/packages/server/scripts/dev-runner.ts index 0d6bac116..ffded1bc4 100644 --- a/packages/server/scripts/dev-runner.ts +++ b/packages/server/scripts/dev-runner.ts @@ -8,6 +8,9 @@ dotenv.config({ }); const daemonRunnerEntry = fileURLToPath(new URL("./supervisor-entrypoint.ts", import.meta.url)); +const inspectArg = process.env.PASEO_NODE_INSPECT ?? "--inspect"; +const inspectArgs = + inspectArg === "0" || inspectArg === "false" || inspectArg === "off" ? [] : [inspectArg]; // The supervisor handles SIGINT/SIGTERM itself and needs time to drain the // worker gracefully. Ignore them here so spawnSync blocks until the supervisor @@ -19,7 +22,7 @@ process.on("SIGTERM", () => {}); const result = spawnSync( process.execPath, [ - "--inspect", + ...inspectArgs, "--heapsnapshot-near-heap-limit=3", "--max-old-space-size=3072", "--report-on-fatalerror", diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 3b4e05e50..123810cb2 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1491,6 +1491,129 @@ describe("DaemonClient", () => { }); }); + test("sends active-scoped fetch_agents_request", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.fetchAgents({ + scope: "active", + page: { limit: 50 }, + }); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(String(mock.sent[0])) as { + type: "session"; + message: { + type: "fetch_agents_request"; + requestId: string; + scope?: "active"; + }; + }; + expect(request.message).toMatchObject({ + type: "fetch_agents_request", + scope: "active", + }); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "fetch_agents_response", + payload: { + requestId: request.message.requestId, + entries: [], + pageInfo: { + nextCursor: null, + prevCursor: null, + hasMore: false, + }, + }, + }, + }), + ); + + await expect(promise).resolves.toMatchObject({ + requestId: request.message.requestId, + entries: [], + }); + }); + + test("fetches paginated agent history separately from active agents", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.fetchAgentHistory({ + page: { limit: 25, cursor: "cursor-1" }, + sort: [{ key: "updated_at", direction: "desc" }], + }); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(String(mock.sent[0])) as { + type: "session"; + message: { + type: "fetch_agent_history_request"; + requestId: string; + page?: { limit: number; cursor?: string }; + }; + }; + expect(request.message.type).toBe("fetch_agent_history_request"); + expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" }); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "fetch_agent_history_response", + payload: { + requestId: request.message.requestId, + entries: [], + pageInfo: { + nextCursor: null, + prevCursor: "cursor-1", + hasMore: false, + }, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + requestId: request.message.requestId, + entries: [], + pageInfo: { + nextCursor: null, + prevCursor: "cursor-1", + hasMore: false, + }, + }); + }); + test("uses server-provided dictation finish timeout budget", async () => { vi.useFakeTimers(); const logger = createMockLogger(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index feccdaa7c..d77508031 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -377,6 +377,19 @@ export type FetchAgentsOptions = Omit }; export type FetchAgentsEntry = FetchAgentsPayload["entries"][number]; export type FetchAgentsPageInfo = FetchAgentsPayload["pageInfo"]; +type FetchAgentHistoryPayload = Extract< + SessionOutboundMessage, + { type: "fetch_agent_history_response" } +>["payload"]; +type FetchAgentHistoryRequest = Extract< + SessionInboundMessage, + { type: "fetch_agent_history_request" } +>; +export type FetchAgentHistoryOptions = Omit & { + requestId?: string; +}; +export type FetchAgentHistoryEntry = FetchAgentHistoryPayload["entries"][number]; +export type FetchAgentHistoryPageInfo = FetchAgentHistoryPayload["pageInfo"]; type FetchWorkspacesPayload = Extract< SessionOutboundMessage, { type: "fetch_workspaces_response" } @@ -1338,6 +1351,7 @@ export class DaemonClient { const message = SessionInboundMessageSchema.parse({ type: "fetch_agents_request", requestId: resolvedRequestId, + ...(options?.scope ? { scope: options.scope } : {}), ...(options?.filter ? { filter: options.filter } : {}), ...(options?.sort ? { sort: options.sort } : {}), ...(options?.page ? { page: options.page } : {}), @@ -1360,6 +1374,32 @@ export class DaemonClient { }); } + async fetchAgentHistory(options?: FetchAgentHistoryOptions): Promise { + const resolvedRequestId = this.createRequestId(options?.requestId); + const message = SessionInboundMessageSchema.parse({ + type: "fetch_agent_history_request", + requestId: resolvedRequestId, + ...(options?.filter ? { filter: options.filter } : {}), + ...(options?.sort ? { sort: options.sort } : {}), + ...(options?.page ? { page: options.page } : {}), + }); + return this.sendRequest({ + requestId: resolvedRequestId, + message, + timeout: 10000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "fetch_agent_history_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + }); + } + async fetchWorkspaces(options?: FetchWorkspacesOptions): Promise { const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index bc7b247bf..f51f267de 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -233,7 +233,7 @@ describe("ProviderSnapshotManager", () => { expect(changes).toHaveLength(1); }); - expect(changes[0]?.cwd).toBe(projectCwd); + expect(changes[0]?.cwd).toBe(homedir()); expect(getProviderEntry(changes[0]?.entries ?? [], "claude")?.status).toBe("ready"); expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading"); @@ -534,22 +534,24 @@ describe("ProviderSnapshotManager", () => { await Promise.resolve(); expect(fetchModels).toHaveBeenCalledTimes(1); - expect(fetchModels).toHaveBeenCalledWith(projectCwd, false); - expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true); + expect(fetchModels).toHaveBeenCalledWith(homedir(), false); + expect(fetchModels).not.toHaveBeenCalledWith(homedir(), true); loadingFetchModels.resolve([createModel("codex", "gpt-5.4")]); await warmUpPromise; expect(fetchModels).toHaveBeenCalledTimes(1); - expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true); + expect(fetchModels).not.toHaveBeenCalledWith(homedir(), true); manager.destroy(); }); - test("settings refresh refreshes only home cwd and invalidates matching provider elsewhere", async () => { + test("settings refresh refreshes the single global provider state once", async () => { const fetchModels = vi .fn<(cwd: string, force: boolean) => Promise>() - .mockImplementation(async (cwd) => [createModel("codex", cwd)]); + .mockImplementation(async (_cwd, force) => [ + createModel("codex", force ? "refreshed" : "initial"), + ]); const { registry } = createRegistry([ createMockProvider({ provider: "codex", @@ -574,24 +576,28 @@ describe("ProviderSnapshotManager", () => { await manager.refreshSettingsSnapshot({ providers: ["codex"] }); - expect(fetchModels.mock.calls.map(([cwd]) => cwd)).toEqual([ - projectACwd, - projectBCwd, - homedir(), + expect(fetchModels.mock.calls).toEqual([ + [homedir(), false], + [homedir(), true], ]); - expect(fetchModels.mock.calls.map(([, force]) => force)).toEqual([false, false, true]); const projectASnapshot = manager.getSnapshot(projectACwd); - expect(getProviderEntry(projectASnapshot, "codex")?.status).toBe("loading"); + expect(getProviderEntry(projectASnapshot, "codex")).toMatchObject({ + provider: "codex", + status: "ready", + models: [createModel("codex", "refreshed")], + }); expect(getProviderEntry(projectASnapshot, "claude")?.status).toBe("ready"); manager.destroy(); }); - test("settings refresh invalidation self-heals workspace snapshots through the next read without force", async () => { + test("settings refresh updates workspace reads through the shared global provider state", async () => { const fetchModels = vi .fn<(cwd: string, force: boolean) => Promise>() - .mockImplementation(async (cwd) => [createModel("codex", cwd)]); + .mockImplementation(async (_cwd, force) => [ + createModel("codex", force ? "refreshed" : "initial"), + ]); const { registry } = createRegistry([ createMockProvider({ provider: "codex", @@ -609,24 +615,15 @@ describe("ProviderSnapshotManager", () => { await manager.refreshSettingsSnapshot({ providers: ["codex"] }); - expect(fetchModels.mock.calls.map(([cwd]) => cwd)).toEqual([projectCwd, homedir()]); - expect(fetchModels.mock.calls.map(([, force]) => force)).toEqual([false, true]); - - const invalidatedSnapshot = manager.getSnapshot(projectCwd); - - expect(getProviderEntry(invalidatedSnapshot, "codex")).toMatchObject({ - provider: "codex", - status: "loading", - }); + expect(fetchModels.mock.calls).toEqual([ + [homedir(), false], + [homedir(), true], + ]); await vi.waitFor(() => { - expect(fetchModels).toHaveBeenCalledTimes(3); - }); - - expect(fetchModels.mock.calls[2]).toEqual([projectCwd, false]); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "refreshed", + ); }); manager.destroy(); @@ -932,7 +929,7 @@ describe("ProviderSnapshotManager", () => { manager.destroy(); }); - test("different cwd keys get independent snapshots", async () => { + test("different cwd keys share the same global provider snapshot state", async () => { const seenCwds: string[] = []; const { registry } = createRegistry([ createMockProvider({ @@ -954,12 +951,12 @@ describe("ProviderSnapshotManager", () => { }); expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.models?.[0]?.id).toBe( - `model:${projectACwd}`, + `model:${homedir()}`, ); expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe( - `model:${projectBCwd}`, + `model:${homedir()}`, ); - expect(seenCwds).toEqual([projectACwd, projectBCwd]); + expect(seenCwds).toEqual([homedir()]); manager.destroy(); }); @@ -991,7 +988,7 @@ describe("ProviderSnapshotManager", () => { manager.destroy(); }); - test("cwd normalization resolves tilde relative paths and trailing slashes before provider calls", async () => { + test("workspace cwd does not affect global provider model fetching", async () => { const seenCwds: string[] = []; const { registry } = createRegistry([ createMockProvider({ @@ -1008,18 +1005,20 @@ describe("ProviderSnapshotManager", () => { manager.getSnapshot("relative-provider-test/.."); await vi.waitFor(() => { - expect(seenCwds).toHaveLength(2); + expect(seenCwds).toHaveLength(1); }); - expect(seenCwds).toEqual([resolve(homedir(), "paseo-provider-test"), resolve(".")]); + expect(seenCwds).toEqual([homedir()]); manager.destroy(); }); - test("workspace refresh refreshes only the requested cwd with force true", async () => { + test("workspace refresh refreshes the shared global provider state with force true", async () => { const fetchModels = vi .fn<(cwd: string, force: boolean) => Promise>() - .mockImplementation(async (cwd) => [createModel("codex", cwd)]); + .mockImplementation(async (_cwd, force) => [ + createModel("codex", force ? "refreshed" : "initial"), + ]); const { registry } = createRegistry([ createMockProvider({ provider: "codex", @@ -1032,18 +1031,17 @@ describe("ProviderSnapshotManager", () => { manager.getSnapshot(projectBCwd); await vi.waitFor(() => { - expect(fetchModels).toHaveBeenCalledTimes(2); + expect(fetchModels).toHaveBeenCalledTimes(1); }); await manager.refreshSnapshotForCwd({ cwd: projectACwd, providers: ["codex"] }); expect(fetchModels.mock.calls).toEqual([ - [projectACwd, false], - [projectBCwd, false], - [projectACwd, true], + [homedir(), false], + [homedir(), true], ]); expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe( - projectBCwd, + "refreshed", ); manager.destroy(); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 0d71c2c51..f2509ecd9 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -50,8 +50,8 @@ export class ProviderSnapshotManager { this.now = options.now ?? Date.now; } - getSnapshot(cwd?: string): ProviderSnapshotEntry[] { - const resolvedCwd = resolveSnapshotCwd(cwd); + getSnapshot(_cwd?: string): ProviderSnapshotEntry[] { + const resolvedCwd = resolveGlobalSnapshotCwd(); const entries = this.snapshots.get(resolvedCwd); if (!entries) { const loadingEntries = this.resetSnapshotToLoading(resolvedCwd); @@ -77,20 +77,20 @@ export class ProviderSnapshotManager { } async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise { - const cwd = resolveSnapshotCwd(options.cwd); + const snapshotCwd = resolveGlobalSnapshotCwd(); const providers = this.resolveRefreshProviders(options.providers); - this.resetSnapshotToLoading(cwd, providers); - this.emitChange(cwd); - await this.refreshProviders(cwd, providers ?? this.getProviderIds()); + this.resetSnapshotToLoading(snapshotCwd, providers); + this.emitChange(snapshotCwd); + await this.refreshProviders(snapshotCwd, providers ?? this.getProviderIds()); if (!providers) { - this.lastCheckedAts.set(cwd, this.now()); + this.lastCheckedAts.set(snapshotCwd, this.now()); } } async refreshSettingsSnapshot( options: Omit = {}, ): Promise { - const homeCwd = resolveSnapshotCwd(); + const homeCwd = resolveGlobalSnapshotCwd(); const providers = this.resolveRefreshProviders(options.providers); const providersToRefresh = providers ?? this.getProviderIds(); @@ -100,28 +100,26 @@ export class ProviderSnapshotManager { if (!providers) { this.lastCheckedAts.set(homeCwd, this.now()); } - - this.invalidateNonHomeSnapshots({ homeCwd, providers }); } async warmUpSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise { - const cwd = resolveSnapshotCwd(options.cwd); + const snapshotCwd = resolveGlobalSnapshotCwd(); const providers = this.resolveRefreshProviders(options.providers); if (options.providers && providers?.length === 0) { return; } - const snapshot = this.snapshots.get(cwd); + const snapshot = this.snapshots.get(snapshotCwd); if (!snapshot) { - this.resetSnapshotToLoading(cwd, providers); + this.resetSnapshotToLoading(snapshotCwd, providers); } else if (providers) { const missingProviders = providers.filter((provider) => !snapshot.has(provider)); if (missingProviders.length > 0) { - this.resetSnapshotToLoading(cwd, missingProviders); + this.resetSnapshotToLoading(snapshotCwd, missingProviders); } } - await this.warmUp(cwd, providers); + await this.warmUp(snapshotCwd, providers); } async refresh(options: ProviderSnapshotRefreshOptions): Promise { @@ -388,42 +386,6 @@ export class ProviderSnapshotManager { const providerIds = new Set(this.getProviderIds()); return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider)); } - - private invalidateNonHomeSnapshots(options: { - homeCwd: string; - providers?: AgentProvider[]; - }): void { - for (const cwd of Array.from(this.snapshots.keys())) { - if (cwd === options.homeCwd) { - continue; - } - - if (!options.providers) { - this.resetSnapshotToLoading(cwd); - this.lastCheckedAts.delete(cwd); - this.providerLoads.delete(cwd); - this.emitChange(cwd); - continue; - } - - const snapshot = this.snapshots.get(cwd); - if (!snapshot) { - continue; - } - let changed = false; - for (const provider of options.providers) { - changed = snapshot.has(provider) || changed; - this.providerLoads.get(cwd)?.delete(provider); - } - this.resetSnapshotToLoading(cwd, options.providers); - if (this.providerLoads.get(cwd)?.size === 0) { - this.providerLoads.delete(cwd); - } - if (changed) { - this.emitChange(cwd); - } - } - } } export function resolveSnapshotCwd(cwd?: string | null): string { @@ -436,6 +398,10 @@ export function resolveSnapshotCwd(cwd?: string | null): string { return resolve(expanded); } +function resolveGlobalSnapshotCwd(): string { + return resolveSnapshotCwd(); +} + function entriesToArray( entries: Map, ): ProviderSnapshotEntry[] { diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.test.ts b/packages/server/src/server/agent/providers/pi-direct-agent.test.ts index 044c0cf5c..07ccecfb0 100644 --- a/packages/server/src/server/agent/providers/pi-direct-agent.test.ts +++ b/packages/server/src/server/agent/providers/pi-direct-agent.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test, vi } from "vitest"; +import type { Api, Model } from "@mariozechner/pi-ai"; +import pino from "pino"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; -import { PiDirectAgentSession, type PiDirectSessionAdapter } from "./pi-direct-agent.js"; +import { + PiDirectAgentClient, + PiDirectAgentSession, + type PiDirectSessionAdapter, +} from "./pi-direct-agent.js"; function createPiSession(prompt: () => Promise): PiDirectSessionAdapter { return { @@ -33,6 +39,15 @@ function createPiSession(prompt: () => Promise): PiDirectSessionAdapter { }; } +function createPiModel(provider: string, id: string): Model { + return { + provider, + id, + name: id, + reasoning: true, + } as Model; +} + describe("PiDirectAgentSession", () => { test("treats SDK request abort rejections as turn cancellations", async () => { const session = new PiDirectAgentSession( @@ -59,3 +74,23 @@ describe("PiDirectAgentSession", () => { ]); }); }); + +describe("PiDirectAgentClient", () => { + test("lists only Pi models with configured auth", async () => { + const client = new PiDirectAgentClient({ + logger: pino({ level: "silent" }), + }); + const registry = { + find: vi.fn(), + getAll: vi.fn(() => [createPiModel("amazon-bedrock", "claude-sonnet-4")]), + getAvailable: vi.fn(() => [createPiModel("anthropic", "claude-opus-4-5")]), + }; + (client as unknown as { modelRegistry: typeof registry }).modelRegistry = registry; + + const models = await client.listModels({ cwd: "/tmp/paseo-pi-test", force: false }); + + expect(registry.getAvailable).toHaveBeenCalledTimes(1); + expect(registry.getAll).not.toHaveBeenCalled(); + expect(models.map((model) => model.id)).toEqual(["anthropic/claude-opus-4-5"]); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.ts b/packages/server/src/server/agent/providers/pi-direct-agent.ts index 510d3b015..8c5c422c6 100644 --- a/packages/server/src/server/agent/providers/pi-direct-agent.ts +++ b/packages/server/src/server/agent/providers/pi-direct-agent.ts @@ -1452,7 +1452,7 @@ export class PiDirectAgentClient implements AgentClient { async listModels(_options: ListModelsOptions): Promise { // Pi Direct uses an in-process global registry; cwd/force are intentionally irrelevant. const models = this.getModelRegistry() - .getAll() + .getAvailable() .map((model) => ({ provider: PI_PROVIDER, id: `${model.provider}/${model.id}`, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1321b14b4..f7e4140d0 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -341,6 +341,11 @@ export type SessionRuntimeMetrics = { }; type FetchAgentsRequestMessage = Extract; +type FetchAgentHistoryRequestMessage = Extract< + SessionInboundMessage, + { type: "fetch_agent_history_request" } +>; +type AgentDirectoryRequestMessage = FetchAgentsRequestMessage | FetchAgentHistoryRequestMessage; type FetchAgentsRequestFilter = NonNullable; type FetchAgentsRequestSort = NonNullable[number]; type FetchAgentsResponsePayload = Extract< @@ -1466,6 +1471,10 @@ export class Session { await this.handleFetchAgents(msg); break; + case "fetch_agent_history_request": + await this.handleFetchAgentHistory(msg); + break; + case "fetch_workspaces_request": await this.handleFetchWorkspacesRequest(msg); break; @@ -5539,21 +5548,68 @@ export class Session { return agent.id.localeCompare(cursor.id); } - private async listFetchAgentsEntries( - request: Extract, - ): Promise<{ + private async buildActiveProjectPlacementsByWorkspaceCwd(): Promise< + Map + > { + const [persistedWorkspaces, persistedProjects] = await Promise.all([ + this.workspaceRegistry.list(), + this.projectRegistry.list(), + ]); + const activeProjects = new Map( + persistedProjects + .filter((project) => !project.archivedAt) + .map((project) => [project.projectId, project] as const), + ); + const placementsByCwd = new Map(); + + for (const workspace of persistedWorkspaces) { + if (workspace.archivedAt) { + continue; + } + const project = activeProjects.get(workspace.projectId); + if (!project) { + continue; + } + placementsByCwd.set( + normalizePersistedWorkspaceId(workspace.cwd), + await this.buildProjectPlacementForWorkspace(workspace, project), + ); + } + + return placementsByCwd; + } + + private async listFetchAgentsEntries(request: AgentDirectoryRequestMessage): Promise<{ entries: FetchAgentsResponseEntry[]; pageInfo: FetchAgentsResponsePageInfo; }> { - const filter = request.filter; + const filter = + request.type === "fetch_agent_history_request" && + request.filter?.includeArchived === undefined + ? { ...request.filter, includeArchived: true } + : request.filter; + const scope = request.type === "fetch_agents_request" ? request.scope : undefined; const sort = this.normalizeFetchAgentsSort(request.sort); - const agents = await this.listAgentPayloads({ + let agents = await this.listAgentPayloads({ labels: filter?.labels, }); + const activePlacementsByCwd = + scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceCwd() : null; + if (activePlacementsByCwd) { + agents = agents.filter( + (agent) => + !agent.archivedAt && activePlacementsByCwd.has(normalizePersistedWorkspaceId(agent.cwd)), + ); + } const placementByCwd = new Map>(); const getPlacement = (cwd: string): Promise => { + if (activePlacementsByCwd) { + return Promise.resolve( + activePlacementsByCwd.get(normalizePersistedWorkspaceId(cwd)) ?? null, + ); + } const existing = placementByCwd.get(cwd); if (existing) { return existing; @@ -5763,16 +5819,24 @@ export class Session { this.projectRegistry.list(), ]); - const activeRecords = persistedWorkspaces.filter((workspace) => !workspace.archivedAt); const activeProjects = new Map( persistedProjects .filter((project) => !project.archivedAt) .map((project) => [project.projectId, project] as const), ); + const archivedProjectIds = new Set( + persistedProjects.filter((project) => project.archivedAt).map((project) => project.projectId), + ); + const activeRecords = persistedWorkspaces.filter( + (workspace) => !workspace.archivedAt && !archivedProjectIds.has(workspace.projectId), + ); const descriptorsByWorkspaceId = new Map(); const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null; const workspaceIdsByDirectory = new Map( - activeRecords.map((workspace) => [workspace.cwd, workspace.workspaceId] as const), + activeRecords.map( + (workspace) => + [normalizePersistedWorkspaceId(workspace.cwd), workspace.workspaceId] as const, + ), ); for (const workspace of activeRecords) { @@ -6433,6 +6497,34 @@ export class Session { } } + private async handleFetchAgentHistory( + request: Extract, + ): Promise { + try { + const payload = await this.listFetchAgentsEntries(request); + this.emit({ + type: "fetch_agent_history_response", + payload: { + requestId: request.requestId, + ...payload, + }, + }); + } catch (error) { + const code = error instanceof SessionRequestError ? error.code : "fetch_agent_history_failed"; + const message = error instanceof Error ? error.message : "Failed to fetch agent history"; + this.sessionLogger.error({ err: error }, "Failed to handle fetch_agent_history_request"); + this.emit({ + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: message, + code, + }, + }); + } + } + private async handleFetchWorkspacesRequest( request: Extract, ): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 7ac35f712..8bd2ce0cb 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -62,6 +62,10 @@ function makeAgent(input: { }; } +function agentIdsFromEntries(entries: Array<{ agent: Pick }>) { + return entries.map((entry) => entry.agent.id); +} + function createNoopWorkspaceGitService() { return { subscribe: async (params: { cwd: string }) => ({ @@ -931,6 +935,368 @@ describe("workspace aggregation", () => { expect(result.entries[0]?.name).not.toBe("Unknown branch"); }); + test("active-scoped fetch_agents includes only unarchived agents in active exact workspaces", async () => { + const session = createSessionForWorkspaceTests() as any; + const archivedAt = "2026-03-02T12:00:00.000Z"; + const activeProject = createPersistedProjectRecord({ + projectId: "proj-active", + rootPath: "/tmp/active", + kind: "non_git", + displayName: "active", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const archivedProject = createPersistedProjectRecord({ + projectId: "proj-archived", + rootPath: "/tmp/archived-project", + kind: "non_git", + displayName: "archived project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + archivedAt, + }); + const activeWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-active", + projectId: activeProject.projectId, + cwd: "/tmp/active", + kind: "directory", + displayName: "active", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const archivedWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-archived", + projectId: activeProject.projectId, + cwd: "/tmp/archived-workspace", + kind: "directory", + displayName: "archived workspace", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + archivedAt, + }); + const workspaceInArchivedProject = createPersistedWorkspaceRecord({ + workspaceId: "ws-archived-project", + projectId: archivedProject.projectId, + cwd: "/tmp/archived-project", + kind: "directory", + displayName: "archived project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + session.projectRegistry.list = async () => [activeProject, archivedProject]; + session.projectRegistry.get = async (projectId: string) => + [activeProject, archivedProject].find((project) => project.projectId === projectId) ?? null; + session.workspaceRegistry.list = async () => [ + activeWorkspace, + archivedWorkspace, + workspaceInArchivedProject, + ]; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "agent-active", + cwd: "/tmp/active", + status: "idle", + updatedAt: "2026-03-01T12:04:00.000Z", + }), + makeAgent({ + id: "agent-subdir", + cwd: "/tmp/active/packages/app", + status: "idle", + updatedAt: "2026-03-01T12:03:00.000Z", + }), + makeAgent({ + id: "agent-archived-workspace", + cwd: "/tmp/archived-workspace", + status: "idle", + updatedAt: "2026-03-01T12:02:00.000Z", + }), + makeAgent({ + id: "agent-archived-project", + cwd: "/tmp/archived-project", + status: "idle", + updatedAt: "2026-03-01T12:01:00.000Z", + }), + { + ...makeAgent({ + id: "agent-archived", + cwd: "/tmp/active", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + archivedAt, + }, + ]; + + const result = await session.listFetchAgentsEntries({ + type: "fetch_agents_request", + requestId: "req-active-agents", + scope: "active", + filter: { includeArchived: true }, + }); + + expect(agentIdsFromEntries(result.entries)).toEqual(["agent-active"]); + expect(result.pageInfo.hasMore).toBe(false); + }); + + test("active-scoped fetch_agents pages within active scope instead of global history", async () => { + const session = createSessionForWorkspaceTests() as any; + const project = createPersistedProjectRecord({ + projectId: "proj-active-pages", + rootPath: "/tmp/pages", + kind: "non_git", + displayName: "pages", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const activeOne = createPersistedWorkspaceRecord({ + workspaceId: "ws-active-one", + projectId: project.projectId, + cwd: "/tmp/pages/one", + kind: "directory", + displayName: "one", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const activeTwo = createPersistedWorkspaceRecord({ + workspaceId: "ws-active-two", + projectId: project.projectId, + cwd: "/tmp/pages/two", + kind: "directory", + displayName: "two", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const archivedWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-stale", + projectId: project.projectId, + cwd: "/tmp/pages/stale", + kind: "directory", + displayName: "stale", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + archivedAt: "2026-03-02T12:00:00.000Z", + }); + + session.projectRegistry.list = async () => [project]; + session.projectRegistry.get = async () => project; + session.workspaceRegistry.list = async () => [activeOne, activeTwo, archivedWorkspace]; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "active-one", + cwd: "/tmp/pages/one", + status: "idle", + updatedAt: "2026-03-01T12:03:00.000Z", + }), + makeAgent({ + id: "stale-between", + cwd: "/tmp/pages/stale", + status: "idle", + updatedAt: "2026-03-01T12:02:00.000Z", + }), + makeAgent({ + id: "active-two", + cwd: "/tmp/pages/two", + status: "idle", + updatedAt: "2026-03-01T12:01:00.000Z", + }), + ]; + + const firstPage = await session.listFetchAgentsEntries({ + type: "fetch_agents_request", + requestId: "req-active-page-1", + scope: "active", + page: { limit: 1 }, + }); + const secondPage = await session.listFetchAgentsEntries({ + type: "fetch_agents_request", + requestId: "req-active-page-2", + scope: "active", + page: { limit: 1, cursor: firstPage.pageInfo.nextCursor }, + }); + + expect(agentIdsFromEntries(firstPage.entries)).toEqual(["active-one"]); + expect(firstPage.pageInfo.hasMore).toBe(true); + expect(agentIdsFromEntries(secondPage.entries)).toEqual(["active-two"]); + expect(secondPage.pageInfo.hasMore).toBe(false); + }); + + test("legacy unscoped fetch_agents keeps global workspace behavior", async () => { + const session = createSessionForWorkspaceTests() as any; + const project = createPersistedProjectRecord({ + projectId: "proj-legacy-global", + rootPath: "/tmp/legacy", + kind: "non_git", + displayName: "legacy", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const activeWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-legacy-active", + projectId: project.projectId, + cwd: "/tmp/legacy/active", + kind: "directory", + displayName: "active", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const archivedWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-legacy-archived", + projectId: project.projectId, + cwd: "/tmp/legacy/archived", + kind: "directory", + displayName: "archived", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + archivedAt: "2026-03-02T12:00:00.000Z", + }); + + session.projectRegistry.get = async () => project; + session.workspaceRegistry.list = async () => [activeWorkspace, archivedWorkspace]; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "legacy-active", + cwd: "/tmp/legacy/active", + status: "idle", + updatedAt: "2026-03-01T12:01:00.000Z", + }), + makeAgent({ + id: "legacy-archived-workspace", + cwd: "/tmp/legacy/archived", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ]; + + const result = await session.listFetchAgentsEntries({ + type: "fetch_agents_request", + requestId: "req-legacy-global", + }); + + expect(agentIdsFromEntries(result.entries)).toEqual([ + "legacy-active", + "legacy-archived-workspace", + ]); + }); + + test("fetch_agent_history_request pages archived historical rows separately", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const session = createSessionForWorkspaceTests() as any; + const project = createPersistedProjectRecord({ + projectId: "proj-history", + rootPath: "/tmp/history", + kind: "non_git", + displayName: "history", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-history", + projectId: project.projectId, + cwd: "/tmp/history", + kind: "directory", + displayName: "history", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + archivedAt: "2026-03-02T12:00:00.000Z", + }); + + session.emit = (message: any) => emitted.push(message); + session.projectRegistry.get = async () => project; + session.workspaceRegistry.list = async () => [workspace]; + session.listAgentPayloads = async () => [ + { + ...makeAgent({ + id: "history-archived", + cwd: "/tmp/history", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + archivedAt: "2026-03-02T12:00:00.000Z", + }, + ]; + + await session.handleMessage({ + type: "fetch_agent_history_request", + requestId: "req-history", + page: { limit: 25 }, + }); + + expect(emitted).toEqual([ + { + type: "fetch_agent_history_response", + payload: expect.objectContaining({ + requestId: "req-history", + entries: [ + expect.objectContaining({ + agent: expect.objectContaining({ id: "history-archived" }), + }), + ], + pageInfo: { + nextCursor: null, + prevCursor: null, + hasMore: false, + }, + }), + }, + ]); + expect(session.agentUpdatesSubscription).toBeNull(); + }); + + test("fetch_agent_request still resolves archived historical agents", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const session = createSessionForWorkspaceTests() as any; + const agent = { + ...makeAgent({ + id: "archived-history-agent", + cwd: "/tmp/history-detail", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + archivedAt: "2026-03-02T12:00:00.000Z", + title: "Archived History Agent", + }; + session.emit = (message: any) => emitted.push(message); + session.resolveAgentIdentifier = async (identifier: string) => + identifier === "Archived History Agent" + ? { ok: true, agentId: agent.id } + : { ok: false, error: `Agent not found: ${identifier}` }; + session.getAgentPayloadById = async (agentId: string) => (agentId === agent.id ? agent : null); + session.buildProjectPlacementForCwd = async (cwd: string) => ({ + projectKey: "proj-history-detail", + projectName: "history detail", + checkout: { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + await session.handleMessage({ + type: "fetch_agent_request", + requestId: "req-agent-detail", + agentId: "Archived History Agent", + }); + + expect(emitted).toEqual([ + { + type: "fetch_agent_response", + payload: { + requestId: "req-agent-detail", + agent, + project: expect.objectContaining({ + projectKey: "proj-history-detail", + }), + error: null, + }, + }, + ]); + }); + test("git branch workspace uses branch as canonical name", async () => { const session = createSessionForWorkspaceTests() as any; session.workspaceRegistry.list = async () => [ diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 600336655..c1d558940 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -726,6 +726,7 @@ export const SendAgentMessageSchema = z.object({ export const FetchAgentsRequestMessageSchema = z.object({ type: z.literal("fetch_agents_request"), requestId: z.string(), + scope: z.enum(["active"]).optional(), filter: AgentDirectoryFilterSchema.optional(), sort: z .array( @@ -787,6 +788,26 @@ export const FetchWorkspacesRequestMessageSchema = z.object({ .optional(), }); +export const FetchAgentHistoryRequestMessageSchema = z.object({ + type: z.literal("fetch_agent_history_request"), + requestId: z.string(), + filter: AgentDirectoryFilterSchema.optional(), + sort: z + .array( + z.object({ + key: z.enum(["status_priority", "created_at", "updated_at", "title"]), + direction: z.enum(["asc", "desc"]), + }), + ) + .optional(), + page: z + .object({ + limit: z.number().int().positive().max(200), + cursor: z.string().min(1).optional(), + }) + .optional(), +}); + export const FetchAgentRequestMessageSchema = z.object({ type: z.literal("fetch_agent_request"), requestId: z.string(), @@ -1541,6 +1562,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AbortRequestMessageSchema, AudioPlayedMessageSchema, FetchAgentsRequestMessageSchema, + FetchAgentHistoryRequestMessageSchema, FetchWorkspacesRequestMessageSchema, FetchAgentRequestMessageSchema, DeleteAgentRequestMessageSchema, @@ -2105,22 +2127,33 @@ export const AgentListMessageSchema = z.object({ }), }); +const AgentDirectoryResponseEntrySchema = z.object({ + agent: AgentSnapshotPayloadSchema, + project: ProjectPlacementPayloadSchema, +}); + +const AgentDirectoryPageInfoSchema = z.object({ + nextCursor: z.string().nullable(), + prevCursor: z.string().nullable(), + hasMore: z.boolean(), +}); + export const FetchAgentsResponseMessageSchema = z.object({ type: z.literal("fetch_agents_response"), payload: z.object({ requestId: z.string(), subscriptionId: z.string().nullable().optional(), - entries: z.array( - z.object({ - agent: AgentSnapshotPayloadSchema, - project: ProjectPlacementPayloadSchema, - }), - ), - pageInfo: z.object({ - nextCursor: z.string().nullable(), - prevCursor: z.string().nullable(), - hasMore: z.boolean(), - }), + entries: z.array(AgentDirectoryResponseEntrySchema), + pageInfo: AgentDirectoryPageInfoSchema, + }), +}); + +export const FetchAgentHistoryResponseMessageSchema = z.object({ + type: z.literal("fetch_agent_history_response"), + payload: z.object({ + requestId: z.string(), + entries: z.array(AgentDirectoryResponseEntrySchema), + pageInfo: AgentDirectoryPageInfoSchema, }), }); @@ -3106,6 +3139,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentStreamMessageSchema, AgentStatusMessageSchema, FetchAgentsResponseMessageSchema, + FetchAgentHistoryResponseMessageSchema, FetchWorkspacesResponseMessageSchema, OpenProjectResponseMessageSchema, StartWorkspaceScriptResponseMessageSchema, @@ -3228,6 +3262,9 @@ export type LegacyEditorTargetId = z.infer; export type EditorTargetId = LiteralUnion; export type EditorTargetDescriptorPayload = z.infer; export type FetchAgentsResponseMessage = z.infer; +export type FetchAgentHistoryResponseMessage = z.infer< + typeof FetchAgentHistoryResponseMessageSchema +>; export type FetchWorkspacesResponseMessage = z.infer; export type ScriptStatusUpdateMessage = z.infer; export type OpenProjectResponseMessage = z.infer; @@ -3301,6 +3338,7 @@ export type ActivityLogPayload = z.infer; // Type exports for inbound message types export type VoiceAudioChunkMessage = z.infer; export type FetchAgentsRequestMessage = z.infer; +export type FetchAgentHistoryRequestMessage = z.infer; export type FetchWorkspacesRequestMessage = z.infer; export type FetchAgentRequestMessage = z.infer; export type SendAgentMessageRequest = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index e2570c1d9..04533a4b8 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -20,6 +20,52 @@ describe("workspace message schemas", () => { expect(parsed.type).toBe("fetch_workspaces_request"); }); + test("parses active-scoped fetch_agents_request as an optional extension", () => { + const legacy = SessionInboundMessageSchema.parse({ + type: "fetch_agents_request", + requestId: "req-agents-legacy", + page: { limit: 50 }, + }); + const activeScoped = SessionInboundMessageSchema.parse({ + type: "fetch_agents_request", + requestId: "req-agents-active", + scope: "active", + page: { limit: 50 }, + subscribe: {}, + }); + + expect(legacy.type).toBe("fetch_agents_request"); + expect(activeScoped.type).toBe("fetch_agents_request"); + if (activeScoped.type !== "fetch_agents_request") { + throw new Error("Expected fetch_agents_request"); + } + expect(activeScoped.scope).toBe("active"); + }); + + test("parses paginated fetch_agent_history_request and response", () => { + const request = SessionInboundMessageSchema.parse({ + type: "fetch_agent_history_request", + requestId: "req-history", + page: { limit: 25, cursor: "cursor-1" }, + sort: [{ key: "updated_at", direction: "desc" }], + }); + const response = SessionOutboundMessageSchema.parse({ + type: "fetch_agent_history_response", + payload: { + requestId: "req-history", + entries: [], + pageInfo: { + nextCursor: "cursor-2", + prevCursor: "cursor-1", + hasMore: true, + }, + }, + }); + + expect(request.type).toBe("fetch_agent_history_request"); + expect(response.type).toBe("fetch_agent_history_response"); + }); + test("parses open_project_request", () => { const parsed = SessionInboundMessageSchema.parse({ type: "open_project_request", diff --git a/paseo.json b/paseo.json index bcd248607..1a7ac8974 100644 --- a/paseo.json +++ b/paseo.json @@ -9,7 +9,7 @@ "scripts": { "daemon": { "type": "service", - "command": "PASEO_LISTEN=0.0.0.0:$PASEO_PORT npm run dev:server" + "command": "PASEO_LISTEN=0.0.0.0:$PASEO_PORT ./scripts/dev-daemon.sh" }, "app": { "type": "service", diff --git a/scripts/dev-daemon.sh b/scripts/dev-daemon.sh new file mode 100755 index 000000000..e737dd906 --- /dev/null +++ b/scripts/dev-daemon.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PATH="$SCRIPT_DIR/../node_modules/.bin:$PATH" + +source "$SCRIPT_DIR/dev-home.sh" +configure_dev_paseo_home + +if [ -z "${PASEO_LOCAL_MODELS_DIR}" ]; then + export PASEO_LOCAL_MODELS_DIR="$HOME/.paseo/models/local-speech" + mkdir -p "$PASEO_LOCAL_MODELS_DIR" +fi + +echo "══════════════════════════════════════════════════════" +echo " Paseo Dev Daemon" +echo "══════════════════════════════════════════════════════" +echo " Home: ${PASEO_HOME}" +echo " Models: ${PASEO_LOCAL_MODELS_DIR}" +echo "══════════════════════════════════════════════════════" + +export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}" +export PASEO_NODE_INSPECT="${PASEO_NODE_INSPECT:---inspect=0}" + +exec npm run dev:server diff --git a/scripts/dev-home.sh b/scripts/dev-home.sh new file mode 100755 index 000000000..caa29871b --- /dev/null +++ b/scripts/dev-home.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +copy_json_tree() { + local source_dir="$1" + local target_dir="$2" + + if [ ! -d "$source_dir" ]; then + return + fi + + mkdir -p "$target_dir" + if command -v rsync >/dev/null 2>&1; then + rsync -a --include='*/' --include='*.json' --exclude='*' "$source_dir/" "$target_dir/" + return + fi + + while IFS= read -r -d '' source_file; do + local relative_path="${source_file#"$source_dir"/}" + local target_file="$target_dir/$relative_path" + mkdir -p "$(dirname "$target_file")" + cp "$source_file" "$target_file" + done < <(find "$source_dir" -type f -name '*.json' -print0) +} + +has_files() { + [ -d "$1" ] && [ -n "$(find "$1" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ] +} + +seed_worktree_paseo_home() { + local source_home="${PASEO_DEV_SEED_HOME:-$HOME/.paseo}" + local target_home="$1" + + if [ ! -d "$source_home" ]; then + echo " Seed: skipped (${source_home} missing)" + return + fi + + if [ "$source_home" = "$target_home" ]; then + echo " Seed: skipped (source is target)" + return + fi + + if [ "${PASEO_DEV_RESET_HOME:-0}" = "1" ]; then + rm -rf "$target_home" + elif has_files "$target_home"; then + echo " Seed: skipped (${target_home} already has data)" + return + fi + + mkdir -p "$target_home" + echo " Seed: copying metadata from ${source_home}" + copy_json_tree "$source_home/agents" "$target_home/agents" + copy_json_tree "$source_home/projects" "$target_home/projects" + if [ -f "$source_home/config.json" ]; then + cp "$source_home/config.json" "$target_home/config.json" + fi + + echo " Seed: copied metadata from ${source_home}" +} + +configure_dev_paseo_home() { + if [ -n "${PASEO_HOME:-}" ]; then + export PASEO_HOME + return + fi + + export PASEO_HOME + local git_dir + local git_common_dir + git_dir="$(git rev-parse --git-dir 2>/dev/null || true)" + git_common_dir="$(git rev-parse --git-common-dir 2>/dev/null || true)" + if [ -n "$git_dir" ] && [ -n "$git_common_dir" ] && [ "$git_dir" != "$git_common_dir" ]; then + local worktree_root + local worktree_name + worktree_root="$(git rev-parse --show-toplevel)" + worktree_name="$(basename "$worktree_root" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//')" + PASEO_HOME="$HOME/.paseo-${worktree_name}" + seed_worktree_paseo_home "$PASEO_HOME" + return + fi + + PASEO_HOME="$(mktemp -d "${TMPDIR:-/tmp}/paseo-dev.XXXXXX")" + trap "rm -rf '$PASEO_HOME'" EXIT +} diff --git a/scripts/dev.sh b/scripts/dev.sh index f826db248..5e59e525d 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -5,22 +5,8 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" export PATH="$SCRIPT_DIR/../node_modules/.bin:$PATH" -# Derive PASEO_HOME: stable name for worktrees, temporary dir otherwise -if [ -z "${PASEO_HOME}" ]; then - export PASEO_HOME - GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)" - GIT_COMMON_DIR="$(git rev-parse --git-common-dir 2>/dev/null || true)" - if [ -n "$GIT_DIR" ] && [ -n "$GIT_COMMON_DIR" ] && [ "$GIT_DIR" != "$GIT_COMMON_DIR" ]; then - # Inside a worktree — derive a stable home from the worktree name - WORKTREE_ROOT="$(git rev-parse --show-toplevel)" - WORKTREE_NAME="$(basename "$WORKTREE_ROOT" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//')" - PASEO_HOME="$HOME/.paseo-${WORKTREE_NAME}" - mkdir -p "$PASEO_HOME" - else - PASEO_HOME="$(mktemp -d "${TMPDIR:-/tmp}/paseo-dev.XXXXXX")" - trap "rm -rf '$PASEO_HOME'" EXIT - fi -fi +source "$SCRIPT_DIR/dev-home.sh" +configure_dev_paseo_home # Share speech models with the main install to avoid duplicate downloads if [ -z "${PASEO_LOCAL_MODELS_DIR}" ]; then @@ -50,5 +36,5 @@ export PASEO_CORS_ORIGINS="*" concurrently \ --names "daemon,metro" \ --prefix-colors "cyan,magenta" \ - "portless run --name daemon sh -c 'PASEO_LISTEN=0.0.0.0:\$PORT exec npm run dev:server'" \ + "portless run --name daemon sh -c 'PASEO_LISTEN=0.0.0.0:\$PORT exec ./scripts/dev-daemon.sh'" \ "cd packages/app && BROWSER=none APP_VARIANT=development EXPO_PUBLIC_LOCAL_DAEMON='${DAEMON_ENDPOINT}' portless run --name app npx expo start"