Improve startup provider snapshots and dev harness
This commit is contained in:
@@ -22,6 +22,11 @@ PASEO_HOME=~/.paseo-blue npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
|
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
|
||||||
|
- In git worktrees, `npm run dev` derives a stable home like `~/.paseo-<worktree-name>`.
|
||||||
|
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
|
### Default ports
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { createTempGitRepo } from "./helpers/workspace";
|
|||||||
import {
|
import {
|
||||||
archiveAgentFromDaemon,
|
archiveAgentFromDaemon,
|
||||||
archiveAgentFromSessions,
|
archiveAgentFromSessions,
|
||||||
|
clickSessionRow,
|
||||||
|
closeWorkspaceAgentTab,
|
||||||
connectArchiveTabDaemonClient,
|
connectArchiveTabDaemonClient,
|
||||||
createIdleAgent,
|
createIdleAgent,
|
||||||
|
expectArchivedAgentFocused,
|
||||||
|
expectSessionRowArchived,
|
||||||
expectSessionRowVisible,
|
expectSessionRowVisible,
|
||||||
expectWorkspaceArchiveOutcome,
|
expectWorkspaceArchiveOutcome,
|
||||||
expectWorkspaceTabHidden,
|
expectWorkspaceTabHidden,
|
||||||
@@ -100,4 +104,26 @@ test.describe("Archive tab reconciliation", () => {
|
|||||||
await passivePage.close();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { Buffer } from "node:buffer";
|
import { Buffer } from "node:buffer";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
||||||
|
|
||||||
type WaitForServerOptions = {
|
type WaitForServerOptions = {
|
||||||
host?: string;
|
host?: string;
|
||||||
@@ -185,6 +186,17 @@ let paseoHome: string | null = null;
|
|||||||
let fakeGhBinDir: string | null = null;
|
let fakeGhBinDir: string | null = null;
|
||||||
let relayProcess: ChildProcess | 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 = {
|
type OfferPayload = {
|
||||||
v: 2;
|
v: 2;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -322,12 +334,36 @@ export default async function globalSetup() {
|
|||||||
const port = await getAvailablePort();
|
const port = await getAvailablePort();
|
||||||
let relayPort = 0;
|
let relayPort = 0;
|
||||||
const metroPort = await getAvailablePort();
|
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();
|
fakeGhBinDir = await createFakeGhBin();
|
||||||
let relayLineBuffer = createLineBuffer();
|
let relayLineBuffer = createLineBuffer();
|
||||||
const metroLineBuffer = createLineBuffer();
|
const metroLineBuffer = createLineBuffer();
|
||||||
const daemonLineBuffer = 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 () => {
|
const cleanup = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
stopProcess(daemonProcess),
|
stopProcess(daemonProcess),
|
||||||
@@ -337,9 +373,11 @@ export default async function globalSetup() {
|
|||||||
daemonProcess = null;
|
daemonProcess = null;
|
||||||
metroProcess = null;
|
metroProcess = null;
|
||||||
relayProcess = null;
|
relayProcess = null;
|
||||||
if (paseoHome) {
|
if (paseoHome && shouldRemovePaseoHome) {
|
||||||
await rm(paseoHome, { recursive: true, force: true });
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
paseoHome = null;
|
paseoHome = null;
|
||||||
|
} else if (paseoHome) {
|
||||||
|
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
||||||
}
|
}
|
||||||
if (fakeGhBinDir) {
|
if (fakeGhBinDir) {
|
||||||
await rm(fakeGhBinDir, { recursive: true, force: true });
|
await rm(fakeGhBinDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -231,6 +231,24 @@ export async function expectWorkspaceArchiveOutcome(
|
|||||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
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 });
|
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clickSessionRow(page: Page, title: string): Promise<void> {
|
||||||
|
const row = getSessionRowByTitle(page, title);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
}
|
||||||
|
|
||||||
export async function archiveAgentFromSessions(
|
export async function archiveAgentFromSessions(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { agentId: string; title: string },
|
input: { agentId: string; title: string },
|
||||||
|
|||||||
@@ -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<CopyStats> {
|
||||||
|
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<CopyStats> {
|
||||||
|
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<PaseoHomeMetadataForkResult> {
|
||||||
|
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],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<WireDirection, { frames: number; bytes: number }>;
|
||||||
|
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<void> {
|
||||||
|
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<string, { frames: number; bytes: number }>();
|
||||||
|
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<string, string>();
|
||||||
|
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<string, number>();
|
||||||
|
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<string>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
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<string>(),
|
||||||
|
};
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ import { getProviderIcon } from "@/components/provider-icons";
|
|||||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
import type { Agent } from "@/stores/session-store";
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
agents: AggregatedAgent[];
|
agents: AggregatedAgent[];
|
||||||
@@ -37,6 +38,62 @@ type FlatListItem =
|
|||||||
| { type: "header"; key: string; title: string }
|
| { type: "header"; key: string; title: string }
|
||||||
| { type: "agent"; key: string; agent: AggregatedAgent };
|
| { 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 {
|
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
@@ -251,6 +308,7 @@ export function AgentList({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rememberArchivedAgentDetail(agent);
|
||||||
const route = prepareWorkspaceTab({
|
const route = prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
|||||||
@@ -878,7 +878,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
entries: snapshotEntries,
|
entries: snapshotEntries,
|
||||||
isLoading: snapshotIsLoading,
|
isLoading: snapshotIsLoading,
|
||||||
refetchIfStale: refetchSnapshotIfStale,
|
refetchIfStale: refetchSnapshotIfStale,
|
||||||
} = useProvidersSnapshot(serverId, agent?.cwd);
|
} = useProvidersSnapshot(serverId);
|
||||||
|
|
||||||
const snapshotSelectedEntry = useMemo(() => {
|
const snapshotSelectedEntry = useMemo(() => {
|
||||||
if (!snapshotEntries || !agent?.provider) {
|
if (!snapshotEntries || !agent?.provider) {
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
error: snapshotError,
|
error: snapshotError,
|
||||||
refresh: refreshSnapshot,
|
refresh: refreshSnapshot,
|
||||||
refetchIfStale: refetchSnapshotIfStale,
|
refetchIfStale: refetchSnapshotIfStale,
|
||||||
} = useProvidersSnapshot(formState.serverId, formState.workingDir);
|
} = useProvidersSnapshot(formState.serverId);
|
||||||
|
|
||||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||||
const snapshotProviderDefinitions = useMemo(
|
const snapshotProviderDefinitions = useMemo(
|
||||||
|
|||||||
@@ -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<ReturnType<DaemonClient["fetchAgentHistory"]>> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<FetchAgentHistoryOptions["sort"]> = [
|
||||||
|
{ 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<AgentHistoryPage> {
|
||||||
|
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<typeof agentHistoryQueryKey>,
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -9,11 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import { providersSnapshotQueryKey, useProvidersSnapshot } from "./use-providers-snapshot";
|
||||||
providersSnapshotQueryKey,
|
|
||||||
shouldApplyProvidersSnapshotUpdate,
|
|
||||||
useProvidersSnapshot,
|
|
||||||
} from "./use-providers-snapshot";
|
|
||||||
|
|
||||||
type ProviderSnapshotUpdateMessage = {
|
type ProviderSnapshotUpdateMessage = {
|
||||||
type: "providers_snapshot_update";
|
type: "providers_snapshot_update";
|
||||||
@@ -80,12 +76,12 @@ function enableProvidersSnapshot(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProvidersSnapshotHook(cwd?: string | null) {
|
function renderProvidersSnapshotHook() {
|
||||||
const queryClient = createQueryClient();
|
const queryClient = createQueryClient();
|
||||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
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;
|
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<void> {
|
async function emitProvidersSnapshotUpdate(
|
||||||
|
entries: ProviderSnapshotEntry[],
|
||||||
|
cwd = "/repo",
|
||||||
|
): Promise<void> {
|
||||||
const listener = snapshotUpdateListeners.at(-1);
|
const listener = snapshotUpdateListeners.at(-1);
|
||||||
expect(listener).toBeDefined();
|
expect(listener).toBeDefined();
|
||||||
|
|
||||||
@@ -132,7 +131,7 @@ async function emitProvidersSnapshotUpdate(entries: ProviderSnapshotEntry[]): Pr
|
|||||||
listener?.({
|
listener?.({
|
||||||
type: "providers_snapshot_update",
|
type: "providers_snapshot_update",
|
||||||
payload: {
|
payload: {
|
||||||
cwd: "/repo",
|
cwd,
|
||||||
entries,
|
entries,
|
||||||
generatedAt: "2026-01-01T00:00:01.000Z",
|
generatedAt: "2026-01-01T00:00:01.000Z",
|
||||||
},
|
},
|
||||||
@@ -155,23 +154,8 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("providers snapshot hook cache scope", () => {
|
describe("providers snapshot hook cache scope", () => {
|
||||||
it("uses no cwd in the settings query key", () => {
|
it("uses a global query key without cwd", () => {
|
||||||
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, null]);
|
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId]);
|
||||||
});
|
|
||||||
|
|
||||||
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("sends no cwd for settings snapshot loads and refreshes", async () => {
|
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({});
|
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();
|
enableProvidersSnapshot();
|
||||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([]));
|
||||||
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||||
@@ -204,21 +188,30 @@ describe("providers snapshot hook cache scope", () => {
|
|||||||
requestId: "workspace-refresh",
|
requestId: "workspace-refresh",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderProvidersSnapshotHook("/repo");
|
const { result } = renderProvidersSnapshotHook();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo" });
|
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({});
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.refresh(["codex"]);
|
await result.current.refresh(["codex"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({
|
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] });
|
||||||
cwd: "/repo",
|
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||||
providers: ["codex"],
|
});
|
||||||
});
|
|
||||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
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 () => {
|
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", [])]))
|
||||||
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
|
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
|
||||||
|
|
||||||
renderProvidersSnapshotHook("/repo");
|
renderProvidersSnapshotHook();
|
||||||
|
|
||||||
await waitForSnapshotReads(1);
|
await waitForSnapshotReads(1);
|
||||||
await emitProvidersSnapshotUpdate([]);
|
await emitProvidersSnapshotUpdate([]);
|
||||||
@@ -237,7 +230,7 @@ describe("providers snapshot hook cache scope", () => {
|
|||||||
await emitProvidersSnapshotUpdate([codexEntry("loading")]);
|
await emitProvidersSnapshotUpdate([codexEntry("loading")]);
|
||||||
await waitForSnapshotReads(2);
|
await waitForSnapshotReads(2);
|
||||||
|
|
||||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||||
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,13 +245,13 @@ describe("providers snapshot hook cache scope", () => {
|
|||||||
.mockResolvedValueOnce(providersSnapshot(entries))
|
.mockResolvedValueOnce(providersSnapshot(entries))
|
||||||
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
|
.mockResolvedValueOnce(providersSnapshot([codexEntry("ready", [readyCodexModel])]));
|
||||||
|
|
||||||
const { result } = renderProvidersSnapshotHook("/repo");
|
const { result } = renderProvidersSnapshotHook();
|
||||||
|
|
||||||
await waitForSnapshotEntries(result, entries);
|
await waitForSnapshotEntries(result, entries);
|
||||||
await openSelectorForSelectedProvider(result);
|
await openSelectorForSelectedProvider(result);
|
||||||
await waitForSnapshotReads(2);
|
await waitForSnapshotReads(2);
|
||||||
|
|
||||||
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||||
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
expect(mockClient.refreshProvidersSnapshot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,7 +259,7 @@ describe("providers snapshot hook cache scope", () => {
|
|||||||
enableProvidersSnapshot();
|
enableProvidersSnapshot();
|
||||||
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])]));
|
mockClient.getProvidersSnapshot.mockResolvedValue(providersSnapshot([codexEntry("ready", [])]));
|
||||||
|
|
||||||
const { result } = renderProvidersSnapshotHook("/repo");
|
const { result } = renderProvidersSnapshotHook();
|
||||||
|
|
||||||
await waitForSnapshotEntries(result, [codexEntry("ready", [])]);
|
await waitForSnapshotEntries(result, [codexEntry("ready", [])]);
|
||||||
await openSelectorForSelectedProvider(result);
|
await openSelectorForSelectedProvider(result);
|
||||||
|
|||||||
@@ -6,40 +6,8 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||||
|
|
||||||
export function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
export function providersSnapshotQueryKey(serverId: string | null) {
|
||||||
const trimmed = cwd?.trim();
|
return ["providersSnapshot", serverId] as const;
|
||||||
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 } : {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseProvidersSnapshotResult {
|
interface UseProvidersSnapshotResult {
|
||||||
@@ -59,23 +27,17 @@ interface UseProvidersSnapshotOptions {
|
|||||||
|
|
||||||
export function useProvidersSnapshot(
|
export function useProvidersSnapshot(
|
||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
cwd?: string | null,
|
|
||||||
options: UseProvidersSnapshotOptions = {},
|
options: UseProvidersSnapshotOptions = {},
|
||||||
): UseProvidersSnapshotResult {
|
): UseProvidersSnapshotResult {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const client = useHostRuntimeClient(serverId ?? "");
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
const enabled = options.enabled ?? true;
|
const enabled = options.enabled ?? true;
|
||||||
const normalizedCwd = cwd?.trim() || undefined;
|
|
||||||
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
|
|
||||||
const supportsSnapshot = useSessionStore(
|
const supportsSnapshot = useSessionStore(
|
||||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
|
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]);
|
||||||
() => providersSnapshotQueryKey(serverId, normalizedCwdKey),
|
|
||||||
[normalizedCwdKey, serverId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const snapshotQuery = useQuery({
|
const snapshotQuery = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
@@ -85,7 +47,7 @@ export function useProvidersSnapshot(
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
|
return client.getProvidersSnapshot({});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,9 +56,7 @@ export function useProvidersSnapshot(
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await client.refreshProvidersSnapshot(
|
await client.refreshProvidersSnapshot(providers ? { providers } : {});
|
||||||
refreshProvidersSnapshotRequest(normalizedCwd, providers),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||||
@@ -110,9 +70,6 @@ export function useProvidersSnapshot(
|
|||||||
if (message.type !== "providers_snapshot_update") {
|
if (message.type !== "providers_snapshot_update") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shouldApplyProvidersSnapshotUpdate(normalizedCwd, message.payload.cwd)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
queryClient.setQueryData(queryKey, {
|
queryClient.setQueryData(queryKey, {
|
||||||
entries: message.payload.entries,
|
entries: message.payload.entries,
|
||||||
generatedAt: message.payload.generatedAt,
|
generatedAt: message.payload.generatedAt,
|
||||||
@@ -127,17 +84,7 @@ export function useProvidersSnapshot(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [
|
}, [client, enabled, isConnected, queryClient, queryKey, serverId, supportsSnapshot]);
|
||||||
client,
|
|
||||||
enabled,
|
|
||||||
isConnected,
|
|
||||||
normalizedCwd,
|
|
||||||
normalizedCwdKey,
|
|
||||||
queryClient,
|
|
||||||
queryKey,
|
|
||||||
serverId,
|
|
||||||
supportsSnapshot,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
async (providers?: AgentProvider[]) => {
|
async (providers?: AgentProvider[]) => {
|
||||||
@@ -145,10 +92,10 @@ export function useProvidersSnapshot(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await refreshSnapshot(providers);
|
await refreshSnapshot(providers);
|
||||||
const snapshot = await client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
|
const snapshot = await client.getProvidersSnapshot({});
|
||||||
queryClient.setQueryData(queryKey, snapshot);
|
queryClient.setQueryData(queryKey, snapshot);
|
||||||
},
|
},
|
||||||
[client, normalizedCwd, queryClient, queryKey, refreshSnapshot],
|
[client, queryClient, queryKey, refreshSnapshot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchIfStale = useCallback(
|
const refetchIfStale = useCallback(
|
||||||
@@ -184,16 +131,11 @@ export function useProvidersSnapshot(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prefetchProvidersSnapshot(
|
export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void {
|
||||||
serverId: string,
|
const queryKey = providersSnapshotQueryKey(serverId);
|
||||||
client: DaemonClient,
|
|
||||||
cwd?: string | null,
|
|
||||||
): void {
|
|
||||||
const normalizedCwd = cwd?.trim() || undefined;
|
|
||||||
const queryKey = providersSnapshotQueryKey(serverId, normalizedCwd);
|
|
||||||
void singletonQueryClient.prefetchQuery({
|
void singletonQueryClient.prefetchQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
queryFn: () => client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)),
|
queryFn: () => client.getProvidersSnapshot({}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const {
|
|||||||
streamRenderCount,
|
streamRenderCount,
|
||||||
latestStreamPermissionKeys,
|
latestStreamPermissionKeys,
|
||||||
latestStreamText,
|
latestStreamText,
|
||||||
|
runtimeIsConnected,
|
||||||
theme,
|
theme,
|
||||||
runtimeClient,
|
runtimeClient,
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
@@ -71,6 +72,7 @@ const {
|
|||||||
streamRenderCount: vi.fn(),
|
streamRenderCount: vi.fn(),
|
||||||
latestStreamPermissionKeys: { current: [] as string[] },
|
latestStreamPermissionKeys: { current: [] as string[] },
|
||||||
latestStreamText: { current: null as string | null },
|
latestStreamText: { current: null as string | null },
|
||||||
|
runtimeIsConnected: { current: false },
|
||||||
theme: {
|
theme: {
|
||||||
colors: {
|
colors: {
|
||||||
foreground: "#ffffff",
|
foreground: "#ffffff",
|
||||||
@@ -121,8 +123,8 @@ function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles)
|
|||||||
vi.mock("@/runtime/host-runtime", () => ({
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
useHosts: () => [{ serverId: "server", label: "Test server" }],
|
useHosts: () => [{ serverId: "server", label: "Test server" }],
|
||||||
useHostRuntimeClient: () => runtimeClient,
|
useHostRuntimeClient: () => runtimeClient,
|
||||||
useHostRuntimeIsConnected: () => false,
|
useHostRuntimeIsConnected: () => runtimeIsConnected.current,
|
||||||
useHostRuntimeConnectionStatus: () => "offline",
|
useHostRuntimeConnectionStatus: () => (runtimeIsConnected.current ? "online" : "offline"),
|
||||||
useHostRuntimeLastError: () => null,
|
useHostRuntimeLastError: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -240,6 +242,51 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeFetchedAgentResult(agent: Agent): Awaited<ReturnType<DaemonClient["fetchAgent"]>> {
|
||||||
|
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()) {
|
function seedReadyAgent(agent: Agent = makeAgent()) {
|
||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
store.initializeSession("server", makeClient());
|
store.initializeSession("server", makeClient());
|
||||||
@@ -367,6 +414,9 @@ describe("AgentPanel render isolation", () => {
|
|||||||
streamRenderCount.mockClear();
|
streamRenderCount.mockClear();
|
||||||
latestStreamPermissionKeys.current = [];
|
latestStreamPermissionKeys.current = [];
|
||||||
latestStreamText.current = null;
|
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 () => {
|
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(latestStreamPermissionKeys.current).toEqual([]);
|
||||||
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 2);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,53 +1,54 @@
|
|||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ActivityIndicator, Text, View } from "react-native";
|
import { ActivityIndicator, Text, View } from "react-native";
|
||||||
import ReanimatedAnimated from "react-native-reanimated";
|
import ReanimatedAnimated from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
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 { shallow, useShallow } from "zustand/shallow";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import invariant from "tiny-invariant";
|
|
||||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
||||||
import { Composer } from "@/components/composer";
|
|
||||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||||
|
import { Composer } from "@/components/composer";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||||
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import {
|
import {
|
||||||
useAgentScreenStateMachine,
|
|
||||||
type AgentScreenAgent,
|
type AgentScreenAgent,
|
||||||
type AgentScreenMissingState,
|
type AgentScreenMissingState,
|
||||||
|
useAgentScreenStateMachine,
|
||||||
} from "@/hooks/use-agent-screen-state-machine";
|
} from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
import {
|
import {
|
||||||
|
type HostRuntimeConnectionStatus,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
useHostRuntimeConnectionStatus,
|
useHostRuntimeConnectionStatus,
|
||||||
useHostRuntimeIsConnected,
|
useHostRuntimeIsConnected,
|
||||||
useHostRuntimeLastError,
|
useHostRuntimeLastError,
|
||||||
useHosts,
|
useHosts,
|
||||||
type HostRuntimeConnectionStatus,
|
|
||||||
} from "@/runtime/host-runtime";
|
} 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 {
|
import {
|
||||||
deriveRouteBottomAnchorIntent,
|
deriveRouteBottomAnchorIntent,
|
||||||
deriveRouteBottomAnchorRequest,
|
deriveRouteBottomAnchorRequest,
|
||||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
} 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 {
|
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
@@ -74,13 +75,63 @@ function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean {
|
||||||
|
return !agent.archivedAt && Boolean(agent.projectPlacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchAgentResult = Awaited<ReturnType<DaemonClient["fetchAgent"]>>;
|
||||||
|
|
||||||
|
function storeFetchedAgentDetail(input: {
|
||||||
|
serverId: string;
|
||||||
|
result: NonNullable<FetchAgentResult>;
|
||||||
|
}): 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(
|
function useAgentPanelDescriptor(
|
||||||
target: { kind: "agent"; agentId: string },
|
target: { kind: "agent"; agentId: string },
|
||||||
context: { serverId: string },
|
context: { serverId: string },
|
||||||
): PanelDescriptor {
|
): PanelDescriptor {
|
||||||
const descriptorState = useSessionStore(
|
const descriptorState = useSessionStore(
|
||||||
useShallow((state) => {
|
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 {
|
return {
|
||||||
provider: agent?.provider ?? "codex",
|
provider: agent?.provider ?? "codex",
|
||||||
title: agent?.title ?? null,
|
title: agent?.title ?? null,
|
||||||
@@ -262,17 +313,27 @@ function AgentPanelBody({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { isArchivingAgent } = useArchiveAgent();
|
const { isArchivingAgent } = useArchiveAgent();
|
||||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||||
const setAgents = useSessionStore((state) => state.setAgents);
|
|
||||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
|
||||||
const projectPlacement = useStoreWithEqualityFn(
|
const projectPlacement = useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
(state) =>
|
(state) => {
|
||||||
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
|
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),
|
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||||
);
|
);
|
||||||
const agentState = useSessionStore(
|
const agentState = useSessionStore(
|
||||||
useShallow((state) => {
|
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 {
|
return {
|
||||||
serverId: agent?.serverId ?? null,
|
serverId: agent?.serverId ?? null,
|
||||||
id: agent?.id ?? null,
|
id: agent?.id ?? null,
|
||||||
@@ -325,29 +386,7 @@ function AgentPanelBody({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = normalizeAgentSnapshot(result.agent, serverId);
|
storeFetchedAgentDetail({ serverId, result });
|
||||||
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;
|
|
||||||
});
|
|
||||||
setLookupState({ tag: "idle" });
|
setLookupState({ tag: "idle" });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -361,17 +400,7 @@ function AgentPanelBody({
|
|||||||
}
|
}
|
||||||
setLookupState({ tag: "error", message });
|
setLookupState({ tag: "error", message });
|
||||||
});
|
});
|
||||||
}, [
|
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
|
||||||
agentId,
|
|
||||||
agentState.id,
|
|
||||||
client,
|
|
||||||
hasSession,
|
|
||||||
isConnected,
|
|
||||||
lookupState.tag,
|
|
||||||
serverId,
|
|
||||||
setAgents,
|
|
||||||
setPendingPermissions,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (lookupState.tag === "not_found") {
|
if (lookupState.tag === "not_found") {
|
||||||
return (
|
return (
|
||||||
@@ -471,7 +500,10 @@ function ChatAgentContent({
|
|||||||
|
|
||||||
const agentState = useSessionStore(
|
const agentState = useSessionStore(
|
||||||
useShallow((state) => {
|
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 {
|
return {
|
||||||
serverId: agent?.serverId ?? null,
|
serverId: agent?.serverId ?? null,
|
||||||
id: agent?.id ?? null,
|
id: agent?.id ?? null,
|
||||||
@@ -486,8 +518,17 @@ function ChatAgentContent({
|
|||||||
);
|
);
|
||||||
const projectPlacement = useStoreWithEqualityFn(
|
const projectPlacement = useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
(state) =>
|
(state) => {
|
||||||
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
|
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),
|
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||||
);
|
);
|
||||||
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||||
@@ -505,8 +546,6 @@ function ChatAgentContent({
|
|||||||
const agentHistorySyncGeneration = useSessionStore((state) =>
|
const agentHistorySyncGeneration = useSessionStore((state) =>
|
||||||
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
|
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 hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||||
const { ensureAgentIsInitialized } = useAgentInitialization({
|
const { ensureAgentIsInitialized } = useAgentInitialization({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -776,7 +815,9 @@ function ChatAgentContent({
|
|||||||
if (attemptToken !== initAttemptTokenRef.current) {
|
if (attemptToken !== initAttemptTokenRef.current) {
|
||||||
return;
|
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) {
|
if (!currentAgent) {
|
||||||
const result = await client.fetchAgent(agentId);
|
const result = await client.fetchAgent(agentId);
|
||||||
if (attemptToken !== initAttemptTokenRef.current) {
|
if (attemptToken !== initAttemptTokenRef.current) {
|
||||||
@@ -789,29 +830,7 @@ function ChatAgentContent({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = normalizeAgentSnapshot(result.agent, serverId);
|
storeFetchedAgentDetail({ serverId, result });
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (attemptToken !== initAttemptTokenRef.current) {
|
if (attemptToken !== initAttemptTokenRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -838,8 +857,6 @@ function ChatAgentContent({
|
|||||||
isConnected,
|
isConnected,
|
||||||
missingAgentState.kind,
|
missingAgentState.kind,
|
||||||
serverId,
|
serverId,
|
||||||
setAgents,
|
|
||||||
setPendingPermissions,
|
|
||||||
shouldUseOptimisticStream,
|
shouldUseOptimisticStream,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1112,7 +1112,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
|
|
||||||
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
|
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
|
||||||
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
||||||
filter: { includeArchived: true },
|
scope: "active",
|
||||||
sort: [{ key: "updated_at", direction: "desc" }],
|
sort: [{ key: "updated_at", direction: "desc" }],
|
||||||
subscribe: { subscriptionId: "app:srv_test" },
|
subscribe: { subscriptionId: "app:srv_test" },
|
||||||
page: { limit: 200 },
|
page: { limit: 200 },
|
||||||
@@ -1160,7 +1160,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
|
|
||||||
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
|
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
|
||||||
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
||||||
filter: { includeArchived: true },
|
scope: "active",
|
||||||
sort: [{ key: "updated_at", direction: "desc" }],
|
sort: [{ key: "updated_at", direction: "desc" }],
|
||||||
subscribe: { subscriptionId: "app:srv_no_session" },
|
subscribe: { subscriptionId: "app:srv_no_session" },
|
||||||
page: { limit: 200 },
|
page: { limit: 200 },
|
||||||
@@ -1169,7 +1169,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
store.syncHosts([]);
|
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({
|
const host = makeHost({
|
||||||
serverId: "srv_paged",
|
serverId: "srv_paged",
|
||||||
connections: [
|
connections: [
|
||||||
@@ -1234,13 +1234,13 @@ describe("HostRuntimeStore", () => {
|
|||||||
|
|
||||||
expect(fakeClient.fetchAgentsCalls).toHaveLength(2);
|
expect(fakeClient.fetchAgentsCalls).toHaveLength(2);
|
||||||
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
||||||
filter: { includeArchived: true },
|
scope: "active",
|
||||||
sort: [{ key: "updated_at", direction: "desc" }],
|
sort: [{ key: "updated_at", direction: "desc" }],
|
||||||
subscribe: { subscriptionId: "app:srv_paged" },
|
subscribe: { subscriptionId: "app:srv_paged" },
|
||||||
page: { limit: 200 },
|
page: { limit: 200 },
|
||||||
});
|
});
|
||||||
expect(fakeClient.fetchAgentsCalls[1]).toEqual({
|
expect(fakeClient.fetchAgentsCalls[1]).toEqual({
|
||||||
filter: { includeArchived: true },
|
scope: "active",
|
||||||
sort: [{ key: "updated_at", direction: "desc" }],
|
sort: [{ key: "updated_at", direction: "desc" }],
|
||||||
page: { limit: 200, cursor: "cursor-page-2" },
|
page: { limit: 200, cursor: "cursor-page-2" },
|
||||||
});
|
});
|
||||||
@@ -1314,13 +1314,13 @@ describe("HostRuntimeStore", () => {
|
|||||||
|
|
||||||
expect(fakeClient.fetchAgentsCalls).toEqual([
|
expect(fakeClient.fetchAgentsCalls).toEqual([
|
||||||
{
|
{
|
||||||
filter: { includeArchived: true },
|
scope: "active",
|
||||||
sort: [{ key: "updated_at", direction: "desc" }],
|
sort: [{ key: "updated_at", direction: "desc" }],
|
||||||
subscribe: { subscriptionId: "app:srv_resubscribe" },
|
subscribe: { subscriptionId: "app:srv_resubscribe" },
|
||||||
page: { limit: 200 },
|
page: { limit: 200 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filter: { includeArchived: true },
|
scope: "active",
|
||||||
sort: [{ key: "updated_at", direction: "desc" }],
|
sort: [{ key: "updated_at", direction: "desc" }],
|
||||||
subscribe: { subscriptionId: "app:srv_resubscribe" },
|
subscribe: { subscriptionId: "app:srv_resubscribe" },
|
||||||
page: { limit: 200 },
|
page: { limit: 200 },
|
||||||
@@ -1331,7 +1331,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
useSessionStore.getState().clearSession(host.serverId);
|
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({
|
const host = makeHost({
|
||||||
serverId: "srv_archived_rehydrate",
|
serverId: "srv_archived_rehydrate",
|
||||||
connections: [
|
connections: [
|
||||||
@@ -1346,15 +1346,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
fakeClient.setConnectionState({ status: "connected" });
|
fakeClient.setConnectionState({ status: "connected" });
|
||||||
fakeClient.fetchAgentsResponses.push(
|
fakeClient.fetchAgentsResponses.push(
|
||||||
makeFetchAgentsPayload({
|
makeFetchAgentsPayload({
|
||||||
entries: [
|
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",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
subscriptionId: "app:srv_archived_rehydrate",
|
subscriptionId: "app:srv_archived_rehydrate",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1397,17 +1389,16 @@ describe("HostRuntimeStore", () => {
|
|||||||
store.syncHosts([host]);
|
store.syncHosts([host]);
|
||||||
|
|
||||||
const timeoutAt = Date.now() + 300;
|
const timeoutAt = Date.now() + 300;
|
||||||
let archivedAt =
|
while (
|
||||||
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
|
useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived") &&
|
||||||
?.archivedAt ?? null;
|
Date.now() < timeoutAt
|
||||||
while (!archivedAt && Date.now() < timeoutAt) {
|
) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
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([]);
|
store.syncHosts([]);
|
||||||
useSessionStore.getState().clearSession(host.serverId);
|
useSessionStore.getState().clearSession(host.serverId);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import equal from "fast-deep-equal/es6";
|
|||||||
import {
|
import {
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
type ConnectionState,
|
type ConnectionState,
|
||||||
|
type FetchAgentsEntry,
|
||||||
type FetchAgentsOptions,
|
type FetchAgentsOptions,
|
||||||
} from "@server/client/daemon-client";
|
} from "@server/client/daemon-client";
|
||||||
import {
|
import {
|
||||||
@@ -31,8 +32,8 @@ import {
|
|||||||
createDesktopLocalDaemonTransportFactory,
|
createDesktopLocalDaemonTransportFactory,
|
||||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||||
import { isDev } from "@/constants/platform";
|
import { isDev } from "@/constants/platform";
|
||||||
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
|
||||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||||
|
|
||||||
@@ -1759,7 +1760,7 @@ export class HostRuntimeStore {
|
|||||||
subscribe?: FetchAgentsOptions["subscribe"];
|
subscribe?: FetchAgentsOptions["subscribe"];
|
||||||
page?: FetchAgentsOptions["page"];
|
page?: FetchAgentsOptions["page"];
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
agents: ReturnType<typeof applyFetchedAgentDirectory>["agents"];
|
agents: ReturnType<typeof replaceFetchedAgentDirectory>["agents"];
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
}> {
|
}> {
|
||||||
const controller = this.controllers.get(input.serverId);
|
const controller = this.controllers.get(input.serverId);
|
||||||
@@ -1778,23 +1779,18 @@ export class HostRuntimeStore {
|
|||||||
let cursor = input.page?.cursor ?? null;
|
let cursor = input.page?.cursor ?? null;
|
||||||
let includeSubscribe = true;
|
let includeSubscribe = true;
|
||||||
let subscriptionId: string | null = null;
|
let subscriptionId: string | null = null;
|
||||||
const allAgents = new Map<string, Agent>();
|
const allEntries: FetchAgentsEntry[] = [];
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const payload = await client.fetchAgents({
|
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,
|
sort: DEFAULT_AGENT_DIRECTORY_SORT,
|
||||||
...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}),
|
...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}),
|
||||||
page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit },
|
page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit },
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageAgents = applyFetchedAgentDirectory({
|
allEntries.push(...payload.entries);
|
||||||
serverId: input.serverId,
|
|
||||||
entries: payload.entries,
|
|
||||||
}).agents;
|
|
||||||
for (const [agentId, agent] of pageAgents) {
|
|
||||||
allAgents.set(agentId, agent);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriptionId = subscriptionId ?? payload.subscriptionId ?? null;
|
subscriptionId = subscriptionId ?? payload.subscriptionId ?? null;
|
||||||
includeSubscribe = false;
|
includeSubscribe = false;
|
||||||
@@ -1810,9 +1806,14 @@ export class HostRuntimeStore {
|
|||||||
cursor = nextCursor;
|
cursor = nextCursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { agents } = replaceFetchedAgentDirectory({
|
||||||
|
serverId: input.serverId,
|
||||||
|
entries: allEntries,
|
||||||
|
});
|
||||||
|
|
||||||
controller.markAgentDirectorySyncReady();
|
controller.markAgentDirectorySyncReady();
|
||||||
return {
|
return {
|
||||||
agents: allAgents,
|
agents,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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<Record<string, unknown>>) =>
|
||||||
|
React.createElement("div", props, children),
|
||||||
|
Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
|
||||||
|
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> = {}): 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(<SessionsScreen serverId="server-1" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(<SessionsScreen serverId="server-1" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).toBeNull();
|
||||||
|
expect(container?.textContent).toContain("No sessions yet");
|
||||||
|
expect(container?.textContent).toContain("Back");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,12 +2,13 @@ import { useMemo, useState, useCallback, useEffect } from "react";
|
|||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import { router } from "expo-router";
|
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 { ChevronLeft } from "lucide-react-native";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { AgentList } from "@/components/agent-list";
|
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";
|
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
export function SessionsScreen({ serverId }: { serverId: string }) {
|
export function SessionsScreen({ serverId }: { serverId: string }) {
|
||||||
@@ -21,10 +22,11 @@ export function SessionsScreen({ serverId }: { serverId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SessionsScreenContent({ serverId }: { serverId: string }) {
|
function SessionsScreenContent({ serverId }: { serverId: string }) {
|
||||||
const { agents, isRevalidating, refreshAll } = useAllAgentsList({
|
const { theme } = useUnistyles();
|
||||||
serverId,
|
const { agents, hasMore, isInitialLoad, isLoadingMore, isRevalidating, loadMore, refreshAll } =
|
||||||
includeArchived: true,
|
useAgentHistory({
|
||||||
});
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
@@ -48,7 +50,11 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<MenuHeader title="Sessions" />
|
<MenuHeader title="Sessions" />
|
||||||
{sortedAgents.length === 0 ? (
|
{isInitialLoad ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
) : sortedAgents.length === 0 ? (
|
||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<Text style={styles.emptyText}>No sessions yet</Text>
|
<Text style={styles.emptyText}>No sessions yet</Text>
|
||||||
<Button
|
<Button
|
||||||
@@ -65,6 +71,15 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
|
|||||||
showCheckoutInfo={false}
|
showCheckoutInfo={false}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
|
listFooterComponent={
|
||||||
|
hasMore ? (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Button variant="ghost" onPress={loadMore} disabled={isLoadingMore}>
|
||||||
|
{isLoadingMore ? "Loading..." : "Load more"}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
showAttentionIndicator={false}
|
showAttentionIndicator={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -88,4 +103,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
},
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -88,6 +88,25 @@ describe("workspace agent visibility", () => {
|
|||||||
expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false);
|
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", () => {
|
it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => {
|
||||||
const knownAgentIds = new Set(["archived-agent"]);
|
const knownAgentIds = new Set(["archived-agent"]);
|
||||||
const activeAgentIds = new Set<string>();
|
const activeAgentIds = new Set<string>();
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ export interface WorkspaceAgentVisibility {
|
|||||||
|
|
||||||
export function deriveWorkspaceAgentVisibility(input: {
|
export function deriveWorkspaceAgentVisibility(input: {
|
||||||
sessionAgents: Map<string, Agent> | undefined;
|
sessionAgents: Map<string, Agent> | undefined;
|
||||||
|
agentDetails?: Map<string, Agent> | undefined;
|
||||||
workspaceDirectory: string | null | undefined;
|
workspaceDirectory: string | null | undefined;
|
||||||
}): WorkspaceAgentVisibility {
|
}): WorkspaceAgentVisibility {
|
||||||
const { sessionAgents, workspaceDirectory } = input;
|
const { sessionAgents, agentDetails, workspaceDirectory } = input;
|
||||||
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory);
|
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory);
|
||||||
if (!sessionAgents || !normalizedWorkspaceDirectory) {
|
if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) {
|
||||||
return {
|
return {
|
||||||
activeAgentIds: new Set<string>(),
|
activeAgentIds: new Set<string>(),
|
||||||
knownAgentIds: new Set<string>(),
|
knownAgentIds: new Set<string>(),
|
||||||
@@ -25,7 +26,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
|||||||
|
|
||||||
const activeAgentIds = new Set<string>();
|
const activeAgentIds = new Set<string>();
|
||||||
const knownAgentIds = new Set<string>();
|
const knownAgentIds = new Set<string>();
|
||||||
for (const agent of sessionAgents.values()) {
|
for (const agent of sessionAgents?.values() ?? []) {
|
||||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,12 @@ export function deriveWorkspaceAgentVisibility(input: {
|
|||||||
activeAgentIds.add(agent.id);
|
activeAgentIds.add(agent.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const agent of agentDetails?.values() ?? []) {
|
||||||
|
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
knownAgentIds.add(agent.id);
|
||||||
|
}
|
||||||
|
|
||||||
return { activeAgentIds, knownAgentIds };
|
return { activeAgentIds, knownAgentIds };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
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 }): {
|
export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }): {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: 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: {
|
export function shouldRenderMissingWorkspaceDescriptor(input: {
|
||||||
workspace: WorkspaceDescriptor | null;
|
workspace: WorkspaceDescriptor | null;
|
||||||
hasHydratedWorkspaces: boolean;
|
hasHydratedWorkspaces: boolean;
|
||||||
|
|||||||
@@ -96,8 +96,9 @@ import {
|
|||||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import {
|
import {
|
||||||
resolveWorkspaceHeader,
|
resolveWorkspaceHeaderRenderState,
|
||||||
shouldRenderMissingWorkspaceDescriptor,
|
shouldRenderMissingWorkspaceDescriptor,
|
||||||
|
type WorkspaceHeaderCheckoutState,
|
||||||
} from "@/screens/workspace/workspace-header-source";
|
} from "@/screens/workspace/workspace-header-source";
|
||||||
import {
|
import {
|
||||||
deriveWorkspaceAgentVisibility,
|
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 {
|
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||||
if (tab.target.kind === "draft") {
|
if (tab.target.kind === "draft") {
|
||||||
return "New Agent";
|
return "New Agent";
|
||||||
@@ -658,9 +647,8 @@ function WorkspaceScreenContent({
|
|||||||
workspaceDescriptor && !workspaceExecutionAuthority,
|
workspaceDescriptor && !workspaceExecutionAuthority,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Warm the server-side provider snapshot for this workspace cwd so the model
|
// Warm the global provider snapshot so the model picker is ready when opened.
|
||||||
// picker is ready when opened. Consumers share the same query cache key.
|
useProvidersSnapshot(normalizedServerId, {
|
||||||
useProvidersSnapshot(normalizedServerId, workspaceDirectory, {
|
|
||||||
enabled: isRouteFocused,
|
enabled: isRouteFocused,
|
||||||
});
|
});
|
||||||
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
|
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
|
||||||
@@ -675,6 +663,7 @@ function WorkspaceScreenContent({
|
|||||||
(state) =>
|
(state) =>
|
||||||
deriveWorkspaceAgentVisibility({
|
deriveWorkspaceAgentVisibility({
|
||||||
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
||||||
|
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
|
||||||
workspaceDirectory,
|
workspaceDirectory,
|
||||||
}),
|
}),
|
||||||
workspaceAgentVisibilityEqual,
|
workspaceAgentVisibilityEqual,
|
||||||
@@ -879,21 +868,36 @@ function WorkspaceScreenContent({
|
|||||||
pendingTerminalCreateInput,
|
pendingTerminalCreateInput,
|
||||||
toast,
|
toast,
|
||||||
]);
|
]);
|
||||||
const workspaceHeader = workspaceDescriptor
|
let workspaceHeaderCheckoutState: WorkspaceHeaderCheckoutState;
|
||||||
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
|
if (isCheckoutStatusLoading) {
|
||||||
: null;
|
workspaceHeaderCheckoutState = { kind: "pending" };
|
||||||
const isWorkspaceHeaderLoading = workspaceHeader === null || isCheckoutStatusLoading;
|
} else if (checkoutQuery.isError || !checkoutQuery.data) {
|
||||||
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
|
workspaceHeaderCheckoutState = { kind: "error" };
|
||||||
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
|
} else {
|
||||||
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(
|
workspaceHeaderCheckoutState = {
|
||||||
workspaceHeaderTitle,
|
kind: "ready",
|
||||||
workspaceHeaderSubtitle,
|
checkout: {
|
||||||
);
|
isGit: checkoutQuery.data.isGit,
|
||||||
|
currentBranch: checkoutQuery.data.currentBranch,
|
||||||
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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 =
|
const currentBranchName =
|
||||||
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
|
workspaceHeaderRenderState.kind === "ready"
|
||||||
? trimNonEmpty(checkoutQuery.data.currentBranch)
|
? workspaceHeaderRenderState.currentBranchName
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const isExplorerOpen = usePanelStore((state) =>
|
const isExplorerOpen = usePanelStore((state) =>
|
||||||
|
|||||||
@@ -6,26 +6,32 @@ vi.hoisted(() => {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
resolveWorkspaceHeader,
|
resolveWorkspaceHeader,
|
||||||
|
resolveWorkspaceHeaderRenderState,
|
||||||
shouldRenderMissingWorkspaceDescriptor,
|
shouldRenderMissingWorkspaceDescriptor,
|
||||||
} from "./workspace-header-source";
|
} from "./workspace-header-source";
|
||||||
import { createSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import { createSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
|
||||||
|
function createWorkspaceDescriptor(input: Partial<WorkspaceDescriptor> = {}): 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", () => {
|
describe("workspace source of truth consumption", () => {
|
||||||
it("uses the same descriptor name in header and sidebar row", () => {
|
it("uses the same descriptor name in header and sidebar row", () => {
|
||||||
const workspace: WorkspaceDescriptor = {
|
const workspace = createWorkspaceDescriptor();
|
||||||
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 header = resolveWorkspaceHeader({ workspace });
|
const header = resolveWorkspaceHeader({ workspace });
|
||||||
const sidebarWorkspace = createSidebarWorkspaceEntry({
|
const sidebarWorkspace = createSidebarWorkspaceEntry({
|
||||||
@@ -39,6 +45,104 @@ describe("workspace source of truth consumption", () => {
|
|||||||
expect(sidebarWorkspace.statusBucket).toBe("running");
|
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", () => {
|
it("renders explicit missing state only after workspace hydration", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldRenderMissingWorkspaceDescriptor({
|
shouldRenderMissingWorkspaceDescriptor({
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ export interface SessionState {
|
|||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
agents: Map<string, Agent>;
|
agents: Map<string, Agent>;
|
||||||
|
agentDetails: Map<string, Agent>;
|
||||||
workspaces: Map<string, WorkspaceDescriptor>;
|
workspaces: Map<string, WorkspaceDescriptor>;
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
@@ -367,6 +368,10 @@ interface SessionStoreActions {
|
|||||||
serverId: string,
|
serverId: string,
|
||||||
agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>),
|
agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>),
|
||||||
) => void;
|
) => void;
|
||||||
|
setAgentDetails: (
|
||||||
|
serverId: string,
|
||||||
|
agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>),
|
||||||
|
) => void;
|
||||||
setWorkspaces: (
|
setWorkspaces: (
|
||||||
serverId: string,
|
serverId: string,
|
||||||
workspaces:
|
workspaces:
|
||||||
@@ -441,6 +446,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
|
|||||||
agentAuthoritativeHistoryApplied: new Map(),
|
agentAuthoritativeHistoryApplied: new Map(),
|
||||||
initializingAgents: new Map(),
|
initializingAgents: new Map(),
|
||||||
agents: new Map(),
|
agents: new Map(),
|
||||||
|
agentDetails: new Map(),
|
||||||
workspaces: new Map(),
|
workspaces: new Map(),
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
fileExplorer: new Map(),
|
fileExplorer: new Map(),
|
||||||
@@ -518,10 +524,13 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
const nextSessions = { ...prev.sessions };
|
const nextSessions = { ...prev.sessions };
|
||||||
delete nextSessions[serverId];
|
delete nextSessions[serverId];
|
||||||
let nextActivity = prev.agentLastActivity;
|
let nextActivity = prev.agentLastActivity;
|
||||||
if (session.agents.size > 0) {
|
if (session.agents.size > 0 || session.agentDetails.size > 0) {
|
||||||
const candidate = new Map(prev.agentLastActivity);
|
const candidate = new Map(prev.agentLastActivity);
|
||||||
let changed = false;
|
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)) {
|
if (candidate.delete(agentId)) {
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
@@ -951,6 +960,26 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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) => {
|
setWorkspaces: (serverId, workspaces) => {
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
const session = prev.sessions[serverId];
|
const session = prev.sessions[serverId];
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
import type { FetchAgentHistoryEntry, FetchAgentsEntry } from "@server/client/daemon-client";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||||
|
|
||||||
|
type AgentDirectoryFetchEntry = FetchAgentsEntry | FetchAgentHistoryEntry;
|
||||||
|
|
||||||
type PendingPermissionEntry = {
|
type PendingPermissionEntry = {
|
||||||
key: string;
|
key: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -11,7 +13,7 @@ type PendingPermissionEntry = {
|
|||||||
|
|
||||||
export function buildAgentDirectoryState(input: {
|
export function buildAgentDirectoryState(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
entries: FetchAgentsEntry[];
|
entries: AgentDirectoryFetchEntry[];
|
||||||
}): {
|
}): {
|
||||||
agents: Map<string, Agent>;
|
agents: Map<string, Agent>;
|
||||||
pendingPermissions: Map<string, PendingPermissionEntry>;
|
pendingPermissions: Map<string, PendingPermissionEntry>;
|
||||||
@@ -40,20 +42,24 @@ export function buildAgentDirectoryState(input: {
|
|||||||
return { agents, pendingPermissions };
|
return { agents, pendingPermissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyFetchedAgentDirectory(input: {
|
export function replaceFetchedAgentDirectory(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
entries: FetchAgentsEntry[];
|
entries: FetchAgentsEntry[];
|
||||||
}): { agents: Map<string, Agent> } {
|
}): { agents: Map<string, Agent> } {
|
||||||
const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input);
|
const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input);
|
||||||
|
|
||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
|
|
||||||
store.setAgents(input.serverId, (prev) => {
|
store.setAgents(input.serverId, fetchedAgents);
|
||||||
const merged = new Map(prev);
|
store.setAgentDetails(input.serverId, (prev) => {
|
||||||
for (const [id, agent] of fetchedAgents) {
|
let next: Map<string, Agent> | null = null;
|
||||||
merged.set(id, agent);
|
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<string, Date>();
|
const lastActivityByAgentId = new Map<string, Date>();
|
||||||
@@ -62,13 +68,7 @@ export function applyFetchedAgentDirectory(input: {
|
|||||||
}
|
}
|
||||||
store.setAgentLastActivityBatch(lastActivityByAgentId);
|
store.setAgentLastActivityBatch(lastActivityByAgentId);
|
||||||
|
|
||||||
store.setPendingPermissions(input.serverId, (prev) => {
|
store.setPendingPermissions(input.serverId, new Map(pendingPermissions));
|
||||||
const merged = new Map(prev);
|
|
||||||
for (const [key, entry] of pendingPermissions) {
|
|
||||||
merged.set(key, entry);
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
});
|
|
||||||
store.setInitializingAgents(input.serverId, new Map());
|
store.setInitializingAgents(input.serverId, new Map());
|
||||||
store.setHasHydratedAgents(input.serverId, true);
|
store.setHasHydratedAgents(input.serverId, true);
|
||||||
return { agents: fetchedAgents };
|
return { agents: fetchedAgents };
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ dotenv.config({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const daemonRunnerEntry = fileURLToPath(new URL("./supervisor-entrypoint.ts", import.meta.url));
|
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
|
// The supervisor handles SIGINT/SIGTERM itself and needs time to drain the
|
||||||
// worker gracefully. Ignore them here so spawnSync blocks until the supervisor
|
// worker gracefully. Ignore them here so spawnSync blocks until the supervisor
|
||||||
@@ -19,7 +22,7 @@ process.on("SIGTERM", () => {});
|
|||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[
|
[
|
||||||
"--inspect",
|
...inspectArgs,
|
||||||
"--heapsnapshot-near-heap-limit=3",
|
"--heapsnapshot-near-heap-limit=3",
|
||||||
"--max-old-space-size=3072",
|
"--max-old-space-size=3072",
|
||||||
"--report-on-fatalerror",
|
"--report-on-fatalerror",
|
||||||
|
|||||||
@@ -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 () => {
|
test("uses server-provided dictation finish timeout budget", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
|
|||||||
@@ -377,6 +377,19 @@ export type FetchAgentsOptions = Omit<FetchAgentsRequest, "type" | "requestId">
|
|||||||
};
|
};
|
||||||
export type FetchAgentsEntry = FetchAgentsPayload["entries"][number];
|
export type FetchAgentsEntry = FetchAgentsPayload["entries"][number];
|
||||||
export type FetchAgentsPageInfo = FetchAgentsPayload["pageInfo"];
|
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<FetchAgentHistoryRequest, "type" | "requestId"> & {
|
||||||
|
requestId?: string;
|
||||||
|
};
|
||||||
|
export type FetchAgentHistoryEntry = FetchAgentHistoryPayload["entries"][number];
|
||||||
|
export type FetchAgentHistoryPageInfo = FetchAgentHistoryPayload["pageInfo"];
|
||||||
type FetchWorkspacesPayload = Extract<
|
type FetchWorkspacesPayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "fetch_workspaces_response" }
|
{ type: "fetch_workspaces_response" }
|
||||||
@@ -1338,6 +1351,7 @@ export class DaemonClient {
|
|||||||
const message = SessionInboundMessageSchema.parse({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
type: "fetch_agents_request",
|
type: "fetch_agents_request",
|
||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
|
...(options?.scope ? { scope: options.scope } : {}),
|
||||||
...(options?.filter ? { filter: options.filter } : {}),
|
...(options?.filter ? { filter: options.filter } : {}),
|
||||||
...(options?.sort ? { sort: options.sort } : {}),
|
...(options?.sort ? { sort: options.sort } : {}),
|
||||||
...(options?.page ? { page: options.page } : {}),
|
...(options?.page ? { page: options.page } : {}),
|
||||||
@@ -1360,6 +1374,32 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchAgentHistory(options?: FetchAgentHistoryOptions): Promise<FetchAgentHistoryPayload> {
|
||||||
|
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<FetchWorkspacesPayload> {
|
async fetchWorkspaces(options?: FetchWorkspacesOptions): Promise<FetchWorkspacesPayload> {
|
||||||
const resolvedRequestId = this.createRequestId(options?.requestId);
|
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||||
const message = SessionInboundMessageSchema.parse({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
expect(changes).toHaveLength(1);
|
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 ?? [], "claude")?.status).toBe("ready");
|
||||||
expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading");
|
expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading");
|
||||||
|
|
||||||
@@ -534,22 +534,24 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(fetchModels).toHaveBeenCalledTimes(1);
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchModels).toHaveBeenCalledWith(projectCwd, false);
|
expect(fetchModels).toHaveBeenCalledWith(homedir(), false);
|
||||||
expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true);
|
expect(fetchModels).not.toHaveBeenCalledWith(homedir(), true);
|
||||||
|
|
||||||
loadingFetchModels.resolve([createModel("codex", "gpt-5.4")]);
|
loadingFetchModels.resolve([createModel("codex", "gpt-5.4")]);
|
||||||
await warmUpPromise;
|
await warmUpPromise;
|
||||||
|
|
||||||
expect(fetchModels).toHaveBeenCalledTimes(1);
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true);
|
expect(fetchModels).not.toHaveBeenCalledWith(homedir(), true);
|
||||||
|
|
||||||
manager.destroy();
|
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
|
const fetchModels = vi
|
||||||
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||||
.mockImplementation(async (cwd) => [createModel("codex", cwd)]);
|
.mockImplementation(async (_cwd, force) => [
|
||||||
|
createModel("codex", force ? "refreshed" : "initial"),
|
||||||
|
]);
|
||||||
const { registry } = createRegistry([
|
const { registry } = createRegistry([
|
||||||
createMockProvider({
|
createMockProvider({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
@@ -574,24 +576,28 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
|
|
||||||
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
||||||
|
|
||||||
expect(fetchModels.mock.calls.map(([cwd]) => cwd)).toEqual([
|
expect(fetchModels.mock.calls).toEqual([
|
||||||
projectACwd,
|
[homedir(), false],
|
||||||
projectBCwd,
|
[homedir(), true],
|
||||||
homedir(),
|
|
||||||
]);
|
]);
|
||||||
expect(fetchModels.mock.calls.map(([, force]) => force)).toEqual([false, false, true]);
|
|
||||||
|
|
||||||
const projectASnapshot = manager.getSnapshot(projectACwd);
|
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");
|
expect(getProviderEntry(projectASnapshot, "claude")?.status).toBe("ready");
|
||||||
|
|
||||||
manager.destroy();
|
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
|
const fetchModels = vi
|
||||||
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||||
.mockImplementation(async (cwd) => [createModel("codex", cwd)]);
|
.mockImplementation(async (_cwd, force) => [
|
||||||
|
createModel("codex", force ? "refreshed" : "initial"),
|
||||||
|
]);
|
||||||
const { registry } = createRegistry([
|
const { registry } = createRegistry([
|
||||||
createMockProvider({
|
createMockProvider({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
@@ -609,24 +615,15 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
|
|
||||||
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
||||||
|
|
||||||
expect(fetchModels.mock.calls.map(([cwd]) => cwd)).toEqual([projectCwd, homedir()]);
|
expect(fetchModels.mock.calls).toEqual([
|
||||||
expect(fetchModels.mock.calls.map(([, force]) => force)).toEqual([false, true]);
|
[homedir(), false],
|
||||||
|
[homedir(), true],
|
||||||
const invalidatedSnapshot = manager.getSnapshot(projectCwd);
|
]);
|
||||||
|
|
||||||
expect(getProviderEntry(invalidatedSnapshot, "codex")).toMatchObject({
|
|
||||||
provider: "codex",
|
|
||||||
status: "loading",
|
|
||||||
});
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(fetchModels).toHaveBeenCalledTimes(3);
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
});
|
"refreshed",
|
||||||
|
);
|
||||||
expect(fetchModels.mock.calls[2]).toEqual([projectCwd, false]);
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
@@ -932,7 +929,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.destroy();
|
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 seenCwds: string[] = [];
|
||||||
const { registry } = createRegistry([
|
const { registry } = createRegistry([
|
||||||
createMockProvider({
|
createMockProvider({
|
||||||
@@ -954,12 +951,12 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.models?.[0]?.id).toBe(
|
expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
`model:${projectACwd}`,
|
`model:${homedir()}`,
|
||||||
);
|
);
|
||||||
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
|
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();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
@@ -991,7 +988,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.destroy();
|
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 seenCwds: string[] = [];
|
||||||
const { registry } = createRegistry([
|
const { registry } = createRegistry([
|
||||||
createMockProvider({
|
createMockProvider({
|
||||||
@@ -1008,18 +1005,20 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.getSnapshot("relative-provider-test/..");
|
manager.getSnapshot("relative-provider-test/..");
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
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();
|
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
|
const fetchModels = vi
|
||||||
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||||
.mockImplementation(async (cwd) => [createModel("codex", cwd)]);
|
.mockImplementation(async (_cwd, force) => [
|
||||||
|
createModel("codex", force ? "refreshed" : "initial"),
|
||||||
|
]);
|
||||||
const { registry } = createRegistry([
|
const { registry } = createRegistry([
|
||||||
createMockProvider({
|
createMockProvider({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
@@ -1032,18 +1031,17 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.getSnapshot(projectBCwd);
|
manager.getSnapshot(projectBCwd);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(fetchModels).toHaveBeenCalledTimes(2);
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
await manager.refreshSnapshotForCwd({ cwd: projectACwd, providers: ["codex"] });
|
await manager.refreshSnapshotForCwd({ cwd: projectACwd, providers: ["codex"] });
|
||||||
|
|
||||||
expect(fetchModels.mock.calls).toEqual([
|
expect(fetchModels.mock.calls).toEqual([
|
||||||
[projectACwd, false],
|
[homedir(), false],
|
||||||
[projectBCwd, false],
|
[homedir(), true],
|
||||||
[projectACwd, true],
|
|
||||||
]);
|
]);
|
||||||
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
|
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
projectBCwd,
|
"refreshed",
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export class ProviderSnapshotManager {
|
|||||||
this.now = options.now ?? Date.now;
|
this.now = options.now ?? Date.now;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
getSnapshot(_cwd?: string): ProviderSnapshotEntry[] {
|
||||||
const resolvedCwd = resolveSnapshotCwd(cwd);
|
const resolvedCwd = resolveGlobalSnapshotCwd();
|
||||||
const entries = this.snapshots.get(resolvedCwd);
|
const entries = this.snapshots.get(resolvedCwd);
|
||||||
if (!entries) {
|
if (!entries) {
|
||||||
const loadingEntries = this.resetSnapshotToLoading(resolvedCwd);
|
const loadingEntries = this.resetSnapshotToLoading(resolvedCwd);
|
||||||
@@ -77,20 +77,20 @@ export class ProviderSnapshotManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||||
const cwd = resolveSnapshotCwd(options.cwd);
|
const snapshotCwd = resolveGlobalSnapshotCwd();
|
||||||
const providers = this.resolveRefreshProviders(options.providers);
|
const providers = this.resolveRefreshProviders(options.providers);
|
||||||
this.resetSnapshotToLoading(cwd, providers);
|
this.resetSnapshotToLoading(snapshotCwd, providers);
|
||||||
this.emitChange(cwd);
|
this.emitChange(snapshotCwd);
|
||||||
await this.refreshProviders(cwd, providers ?? this.getProviderIds());
|
await this.refreshProviders(snapshotCwd, providers ?? this.getProviderIds());
|
||||||
if (!providers) {
|
if (!providers) {
|
||||||
this.lastCheckedAts.set(cwd, this.now());
|
this.lastCheckedAts.set(snapshotCwd, this.now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshSettingsSnapshot(
|
async refreshSettingsSnapshot(
|
||||||
options: Omit<ProviderSnapshotRefreshOptions, "cwd"> = {},
|
options: Omit<ProviderSnapshotRefreshOptions, "cwd"> = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const homeCwd = resolveSnapshotCwd();
|
const homeCwd = resolveGlobalSnapshotCwd();
|
||||||
const providers = this.resolveRefreshProviders(options.providers);
|
const providers = this.resolveRefreshProviders(options.providers);
|
||||||
const providersToRefresh = providers ?? this.getProviderIds();
|
const providersToRefresh = providers ?? this.getProviderIds();
|
||||||
|
|
||||||
@@ -100,28 +100,26 @@ export class ProviderSnapshotManager {
|
|||||||
if (!providers) {
|
if (!providers) {
|
||||||
this.lastCheckedAts.set(homeCwd, this.now());
|
this.lastCheckedAts.set(homeCwd, this.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
this.invalidateNonHomeSnapshots({ homeCwd, providers });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async warmUpSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
async warmUpSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||||
const cwd = resolveSnapshotCwd(options.cwd);
|
const snapshotCwd = resolveGlobalSnapshotCwd();
|
||||||
const providers = this.resolveRefreshProviders(options.providers);
|
const providers = this.resolveRefreshProviders(options.providers);
|
||||||
if (options.providers && providers?.length === 0) {
|
if (options.providers && providers?.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = this.snapshots.get(cwd);
|
const snapshot = this.snapshots.get(snapshotCwd);
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
this.resetSnapshotToLoading(cwd, providers);
|
this.resetSnapshotToLoading(snapshotCwd, providers);
|
||||||
} else if (providers) {
|
} else if (providers) {
|
||||||
const missingProviders = providers.filter((provider) => !snapshot.has(provider));
|
const missingProviders = providers.filter((provider) => !snapshot.has(provider));
|
||||||
if (missingProviders.length > 0) {
|
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<void> {
|
async refresh(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||||
@@ -388,42 +386,6 @@ export class ProviderSnapshotManager {
|
|||||||
const providerIds = new Set(this.getProviderIds());
|
const providerIds = new Set(this.getProviderIds());
|
||||||
return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider));
|
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 {
|
export function resolveSnapshotCwd(cwd?: string | null): string {
|
||||||
@@ -436,6 +398,10 @@ export function resolveSnapshotCwd(cwd?: string | null): string {
|
|||||||
return resolve(expanded);
|
return resolve(expanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveGlobalSnapshotCwd(): string {
|
||||||
|
return resolveSnapshotCwd();
|
||||||
|
}
|
||||||
|
|
||||||
function entriesToArray(
|
function entriesToArray(
|
||||||
entries: Map<AgentProvider, ProviderSnapshotEntry>,
|
entries: Map<AgentProvider, ProviderSnapshotEntry>,
|
||||||
): ProviderSnapshotEntry[] {
|
): ProviderSnapshotEntry[] {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
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 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<void>): PiDirectSessionAdapter {
|
function createPiSession(prompt: () => Promise<void>): PiDirectSessionAdapter {
|
||||||
return {
|
return {
|
||||||
@@ -33,6 +39,15 @@ function createPiSession(prompt: () => Promise<void>): PiDirectSessionAdapter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPiModel(provider: string, id: string): Model<Api> {
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
reasoning: true,
|
||||||
|
} as Model<Api>;
|
||||||
|
}
|
||||||
|
|
||||||
describe("PiDirectAgentSession", () => {
|
describe("PiDirectAgentSession", () => {
|
||||||
test("treats SDK request abort rejections as turn cancellations", async () => {
|
test("treats SDK request abort rejections as turn cancellations", async () => {
|
||||||
const session = new PiDirectAgentSession(
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1452,7 +1452,7 @@ export class PiDirectAgentClient implements AgentClient {
|
|||||||
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
// Pi Direct uses an in-process global registry; cwd/force are intentionally irrelevant.
|
// Pi Direct uses an in-process global registry; cwd/force are intentionally irrelevant.
|
||||||
const models = this.getModelRegistry()
|
const models = this.getModelRegistry()
|
||||||
.getAll()
|
.getAvailable()
|
||||||
.map((model) => ({
|
.map((model) => ({
|
||||||
provider: PI_PROVIDER,
|
provider: PI_PROVIDER,
|
||||||
id: `${model.provider}/${model.id}`,
|
id: `${model.provider}/${model.id}`,
|
||||||
|
|||||||
@@ -341,6 +341,11 @@ export type SessionRuntimeMetrics = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type FetchAgentsRequestMessage = Extract<SessionInboundMessage, { type: "fetch_agents_request" }>;
|
type FetchAgentsRequestMessage = Extract<SessionInboundMessage, { type: "fetch_agents_request" }>;
|
||||||
|
type FetchAgentHistoryRequestMessage = Extract<
|
||||||
|
SessionInboundMessage,
|
||||||
|
{ type: "fetch_agent_history_request" }
|
||||||
|
>;
|
||||||
|
type AgentDirectoryRequestMessage = FetchAgentsRequestMessage | FetchAgentHistoryRequestMessage;
|
||||||
type FetchAgentsRequestFilter = NonNullable<FetchAgentsRequestMessage["filter"]>;
|
type FetchAgentsRequestFilter = NonNullable<FetchAgentsRequestMessage["filter"]>;
|
||||||
type FetchAgentsRequestSort = NonNullable<FetchAgentsRequestMessage["sort"]>[number];
|
type FetchAgentsRequestSort = NonNullable<FetchAgentsRequestMessage["sort"]>[number];
|
||||||
type FetchAgentsResponsePayload = Extract<
|
type FetchAgentsResponsePayload = Extract<
|
||||||
@@ -1466,6 +1471,10 @@ export class Session {
|
|||||||
await this.handleFetchAgents(msg);
|
await this.handleFetchAgents(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "fetch_agent_history_request":
|
||||||
|
await this.handleFetchAgentHistory(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
case "fetch_workspaces_request":
|
case "fetch_workspaces_request":
|
||||||
await this.handleFetchWorkspacesRequest(msg);
|
await this.handleFetchWorkspacesRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -5539,21 +5548,68 @@ export class Session {
|
|||||||
return agent.id.localeCompare(cursor.id);
|
return agent.id.localeCompare(cursor.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listFetchAgentsEntries(
|
private async buildActiveProjectPlacementsByWorkspaceCwd(): Promise<
|
||||||
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
|
Map<string, ProjectPlacementPayload>
|
||||||
): Promise<{
|
> {
|
||||||
|
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<string, ProjectPlacementPayload>();
|
||||||
|
|
||||||
|
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[];
|
entries: FetchAgentsResponseEntry[];
|
||||||
pageInfo: FetchAgentsResponsePageInfo;
|
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 sort = this.normalizeFetchAgentsSort(request.sort);
|
||||||
|
|
||||||
const agents = await this.listAgentPayloads({
|
let agents = await this.listAgentPayloads({
|
||||||
labels: filter?.labels,
|
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<string, Promise<ProjectPlacementPayload | null>>();
|
const placementByCwd = new Map<string, Promise<ProjectPlacementPayload | null>>();
|
||||||
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload | null> => {
|
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload | null> => {
|
||||||
|
if (activePlacementsByCwd) {
|
||||||
|
return Promise.resolve(
|
||||||
|
activePlacementsByCwd.get(normalizePersistedWorkspaceId(cwd)) ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
const existing = placementByCwd.get(cwd);
|
const existing = placementByCwd.get(cwd);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
@@ -5763,16 +5819,24 @@ export class Session {
|
|||||||
this.projectRegistry.list(),
|
this.projectRegistry.list(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activeRecords = persistedWorkspaces.filter((workspace) => !workspace.archivedAt);
|
|
||||||
const activeProjects = new Map(
|
const activeProjects = new Map(
|
||||||
persistedProjects
|
persistedProjects
|
||||||
.filter((project) => !project.archivedAt)
|
.filter((project) => !project.archivedAt)
|
||||||
.map((project) => [project.projectId, project] as const),
|
.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<string, WorkspaceDescriptorPayload>();
|
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
|
||||||
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
|
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
|
||||||
const workspaceIdsByDirectory = new Map(
|
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) {
|
for (const workspace of activeRecords) {
|
||||||
@@ -6433,6 +6497,34 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleFetchAgentHistory(
|
||||||
|
request: Extract<SessionInboundMessage, { type: "fetch_agent_history_request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
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(
|
private async handleFetchWorkspacesRequest(
|
||||||
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
|
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ function makeAgent(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function agentIdsFromEntries(entries: Array<{ agent: Pick<AgentSnapshotPayload, "id"> }>) {
|
||||||
|
return entries.map((entry) => entry.agent.id);
|
||||||
|
}
|
||||||
|
|
||||||
function createNoopWorkspaceGitService() {
|
function createNoopWorkspaceGitService() {
|
||||||
return {
|
return {
|
||||||
subscribe: async (params: { cwd: string }) => ({
|
subscribe: async (params: { cwd: string }) => ({
|
||||||
@@ -931,6 +935,368 @@ describe("workspace aggregation", () => {
|
|||||||
expect(result.entries[0]?.name).not.toBe("Unknown branch");
|
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 () => {
|
test("git branch workspace uses branch as canonical name", async () => {
|
||||||
const session = createSessionForWorkspaceTests() as any;
|
const session = createSessionForWorkspaceTests() as any;
|
||||||
session.workspaceRegistry.list = async () => [
|
session.workspaceRegistry.list = async () => [
|
||||||
|
|||||||
@@ -726,6 +726,7 @@ export const SendAgentMessageSchema = z.object({
|
|||||||
export const FetchAgentsRequestMessageSchema = z.object({
|
export const FetchAgentsRequestMessageSchema = z.object({
|
||||||
type: z.literal("fetch_agents_request"),
|
type: z.literal("fetch_agents_request"),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
|
scope: z.enum(["active"]).optional(),
|
||||||
filter: AgentDirectoryFilterSchema.optional(),
|
filter: AgentDirectoryFilterSchema.optional(),
|
||||||
sort: z
|
sort: z
|
||||||
.array(
|
.array(
|
||||||
@@ -787,6 +788,26 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
|
|||||||
.optional(),
|
.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({
|
export const FetchAgentRequestMessageSchema = z.object({
|
||||||
type: z.literal("fetch_agent_request"),
|
type: z.literal("fetch_agent_request"),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
@@ -1541,6 +1562,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AbortRequestMessageSchema,
|
AbortRequestMessageSchema,
|
||||||
AudioPlayedMessageSchema,
|
AudioPlayedMessageSchema,
|
||||||
FetchAgentsRequestMessageSchema,
|
FetchAgentsRequestMessageSchema,
|
||||||
|
FetchAgentHistoryRequestMessageSchema,
|
||||||
FetchWorkspacesRequestMessageSchema,
|
FetchWorkspacesRequestMessageSchema,
|
||||||
FetchAgentRequestMessageSchema,
|
FetchAgentRequestMessageSchema,
|
||||||
DeleteAgentRequestMessageSchema,
|
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({
|
export const FetchAgentsResponseMessageSchema = z.object({
|
||||||
type: z.literal("fetch_agents_response"),
|
type: z.literal("fetch_agents_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
subscriptionId: z.string().nullable().optional(),
|
subscriptionId: z.string().nullable().optional(),
|
||||||
entries: z.array(
|
entries: z.array(AgentDirectoryResponseEntrySchema),
|
||||||
z.object({
|
pageInfo: AgentDirectoryPageInfoSchema,
|
||||||
agent: AgentSnapshotPayloadSchema,
|
}),
|
||||||
project: ProjectPlacementPayloadSchema,
|
});
|
||||||
}),
|
|
||||||
),
|
export const FetchAgentHistoryResponseMessageSchema = z.object({
|
||||||
pageInfo: z.object({
|
type: z.literal("fetch_agent_history_response"),
|
||||||
nextCursor: z.string().nullable(),
|
payload: z.object({
|
||||||
prevCursor: z.string().nullable(),
|
requestId: z.string(),
|
||||||
hasMore: z.boolean(),
|
entries: z.array(AgentDirectoryResponseEntrySchema),
|
||||||
}),
|
pageInfo: AgentDirectoryPageInfoSchema,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3106,6 +3139,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AgentStreamMessageSchema,
|
AgentStreamMessageSchema,
|
||||||
AgentStatusMessageSchema,
|
AgentStatusMessageSchema,
|
||||||
FetchAgentsResponseMessageSchema,
|
FetchAgentsResponseMessageSchema,
|
||||||
|
FetchAgentHistoryResponseMessageSchema,
|
||||||
FetchWorkspacesResponseMessageSchema,
|
FetchWorkspacesResponseMessageSchema,
|
||||||
OpenProjectResponseMessageSchema,
|
OpenProjectResponseMessageSchema,
|
||||||
StartWorkspaceScriptResponseMessageSchema,
|
StartWorkspaceScriptResponseMessageSchema,
|
||||||
@@ -3228,6 +3262,9 @@ export type LegacyEditorTargetId = z.infer<typeof LegacyEditorTargetIdSchema>;
|
|||||||
export type EditorTargetId = LiteralUnion<KnownEditorTargetId, string>;
|
export type EditorTargetId = LiteralUnion<KnownEditorTargetId, string>;
|
||||||
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
|
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
|
||||||
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
|
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
|
||||||
|
export type FetchAgentHistoryResponseMessage = z.infer<
|
||||||
|
typeof FetchAgentHistoryResponseMessageSchema
|
||||||
|
>;
|
||||||
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
||||||
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
|
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
|
||||||
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
|
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
|
||||||
@@ -3301,6 +3338,7 @@ export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
|||||||
// Type exports for inbound message types
|
// Type exports for inbound message types
|
||||||
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
|
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
|
||||||
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
|
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
|
||||||
|
export type FetchAgentHistoryRequestMessage = z.infer<typeof FetchAgentHistoryRequestMessageSchema>;
|
||||||
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
|
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
|
||||||
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
||||||
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
||||||
|
|||||||
@@ -20,6 +20,52 @@ describe("workspace message schemas", () => {
|
|||||||
expect(parsed.type).toBe("fetch_workspaces_request");
|
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", () => {
|
test("parses open_project_request", () => {
|
||||||
const parsed = SessionInboundMessageSchema.parse({
|
const parsed = SessionInboundMessageSchema.parse({
|
||||||
type: "open_project_request",
|
type: "open_project_request",
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"daemon": {
|
"daemon": {
|
||||||
"type": "service",
|
"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": {
|
"app": {
|
||||||
"type": "service",
|
"type": "service",
|
||||||
|
|||||||
Executable
+25
@@ -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
|
||||||
Executable
+84
@@ -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
|
||||||
|
}
|
||||||
+3
-17
@@ -5,22 +5,8 @@ set -e
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
export PATH="$SCRIPT_DIR/../node_modules/.bin:$PATH"
|
export PATH="$SCRIPT_DIR/../node_modules/.bin:$PATH"
|
||||||
|
|
||||||
# Derive PASEO_HOME: stable name for worktrees, temporary dir otherwise
|
source "$SCRIPT_DIR/dev-home.sh"
|
||||||
if [ -z "${PASEO_HOME}" ]; then
|
configure_dev_paseo_home
|
||||||
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
|
|
||||||
|
|
||||||
# Share speech models with the main install to avoid duplicate downloads
|
# Share speech models with the main install to avoid duplicate downloads
|
||||||
if [ -z "${PASEO_LOCAL_MODELS_DIR}" ]; then
|
if [ -z "${PASEO_LOCAL_MODELS_DIR}" ]; then
|
||||||
@@ -50,5 +36,5 @@ export PASEO_CORS_ORIGINS="*"
|
|||||||
concurrently \
|
concurrently \
|
||||||
--names "daemon,metro" \
|
--names "daemon,metro" \
|
||||||
--prefix-colors "cyan,magenta" \
|
--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"
|
"cd packages/app && BROWSER=none APP_VARIANT=development EXPO_PUBLIC_LOCAL_DAEMON='${DAEMON_ENDPOINT}' portless run --name app npx expo start"
|
||||||
|
|||||||
Reference in New Issue
Block a user