Fix provider snapshot warm reads

This commit is contained in:
Mohamed Boudra
2026-06-18 11:26:27 +07:00
parent 6fc52d85cb
commit 6c0b5c7640
4 changed files with 301 additions and 35 deletions

View File

@@ -158,6 +158,95 @@ describe("ProviderSnapshotManager public surface", () => {
}
});
test("wait:true returns a warm provider without refreshing it", async () => {
const cwd = "/tmp/project";
const isAvailable = vi.fn(async () => true);
const listModels = vi.fn(async () => [
{
provider: "codex",
id: "gpt-5.4-mini",
label: "GPT 5.4 Mini",
},
]);
const listModes = vi.fn(async () => [] as AgentMode[]);
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: {
codex: createExtraClient("codex", { isAvailable, listModels, listModes }),
},
});
const listener = vi.fn();
manager.on("change", listener);
try {
const [first] = await manager.listProviders({ cwd, providers: ["codex"], wait: true });
expect(first).toMatchObject({ provider: "codex", status: "ready" });
expect(isAvailable).toHaveBeenCalledTimes(1);
expect(listModels).toHaveBeenCalledTimes(1);
expect(listModes).toHaveBeenCalledTimes(1);
listener.mockClear();
const [second] = await manager.listProviders({ cwd, providers: ["codex"], wait: true });
expect(second).toEqual(first);
expect(isAvailable).toHaveBeenCalledTimes(1);
expect(listModels).toHaveBeenCalledTimes(1);
expect(listModes).toHaveBeenCalledTimes(1);
expect(listener).not.toHaveBeenCalled();
} finally {
manager.destroy();
}
});
test("explicit refresh re-probes only the requested warm provider", async () => {
const cwd = "/tmp/project";
const isAvailableCodex = vi.fn(async () => true);
const listCodexModels = vi.fn(async () => [
{
provider: "codex",
id: "gpt-5.4-mini",
label: "GPT 5.4 Mini",
},
]);
const listCodexModes = vi.fn(async () => [] as AgentMode[]);
const isAvailableClaude = vi.fn(async () => true);
const listClaudeModels = vi.fn(async () => [
{
provider: "claude",
id: "claude-opus-4.5",
label: "Claude Opus 4.5",
},
]);
const listClaudeModes = vi.fn(async () => [] as AgentMode[]);
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: {
codex: createExtraClient("codex", {
isAvailable: isAvailableCodex,
listModels: listCodexModels,
listModes: listCodexModes,
}),
claude: createExtraClient("claude", {
isAvailable: isAvailableClaude,
listModels: listClaudeModels,
listModes: listClaudeModes,
}),
},
});
try {
await manager.listProviders({ cwd, providers: ["codex", "claude"], wait: true });
await manager.refreshSnapshotForCwd({ cwd, providers: ["codex"] });
expect(isAvailableCodex).toHaveBeenCalledTimes(2);
expect(listCodexModels).toHaveBeenCalledTimes(2);
expect(listCodexModes).toHaveBeenCalledTimes(2);
expect(isAvailableClaude).toHaveBeenCalledTimes(1);
expect(listClaudeModels).toHaveBeenCalledTimes(1);
expect(listClaudeModes).toHaveBeenCalledTimes(1);
} finally {
manager.destroy();
}
});
test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => {
// never-resolving isAvailable forces the timeout path
const isAvailable = vi.fn(() => new Promise<boolean>(() => {}));

View File

@@ -150,31 +150,11 @@ export class ProviderSnapshotManager {
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
const resolvedCwd = resolveSnapshotCwd(cwd);
const entries = this.snapshots.get(resolvedCwd);
if (!entries) {
const loadingEntries = this.resetSnapshotToLoading(resolvedCwd);
void this.warmUp(resolvedCwd);
return entriesToArray(loadingEntries);
const providersToWarm = this.resolveProvidersToWarm(resolvedCwd);
if (providersToWarm.length > 0) {
void this.warmUp(resolvedCwd, providersToWarm);
}
const missingProviders = this.getProviderIds().filter((provider) => !entries.has(provider));
if (missingProviders.length > 0) {
const loadingEntries = this.createLoadingEntries();
for (const provider of missingProviders) {
const loadingEntry = loadingEntries.get(provider);
if (loadingEntry) {
entries.set(provider, loadingEntry);
}
}
void this.warmUp(resolvedCwd, missingProviders);
}
const providerLoads = this.providerLoads.get(resolvedCwd);
const loadingProviders = Array.from(entries.values())
.filter((entry) => entry.status === "loading" && !providerLoads?.has(entry.provider))
.map((entry) => entry.provider);
if (loadingProviders.length > 0) {
void this.warmUp(resolvedCwd, loadingProviders);
}
return entriesToArray(entries);
return entriesToArray(this.getOrCreateSnapshot(resolvedCwd));
}
async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
@@ -205,17 +185,11 @@ export class ProviderSnapshotManager {
return;
}
const snapshot = this.snapshots.get(snapshotCwd);
if (!snapshot) {
this.resetSnapshotToLoading(snapshotCwd, providers);
} else if (providers) {
const missingProviders = providers.filter((provider) => !snapshot.has(provider));
if (missingProviders.length > 0) {
this.resetSnapshotToLoading(snapshotCwd, missingProviders);
}
const providersToWarm = this.resolveProvidersToWarm(snapshotCwd, providers);
if (providersToWarm.length === 0) {
return;
}
await this.warmUp(snapshotCwd, providers);
await this.warmUp(snapshotCwd, providersToWarm);
}
async refresh(options: ProviderSnapshotRefreshOptions): Promise<void> {
@@ -520,6 +494,22 @@ export class ProviderSnapshotManager {
await this.loadProviders({ cwd, providers, force: true });
}
private resolveProvidersToWarm(cwd: string, providers?: AgentProvider[]): AgentProvider[] {
const providersToInspect = providers ?? this.getProviderIds();
const snapshot = this.snapshots.get(cwd);
if (!snapshot) {
this.resetSnapshotToLoading(cwd, providers);
return providersToInspect;
}
const missingProviders = providersToInspect.filter((provider) => !snapshot.has(provider));
if (missingProviders.length > 0) {
this.resetSnapshotToLoading(cwd, missingProviders);
}
return providersToInspect.filter((provider) => snapshot.get(provider)?.status === "loading");
}
private clearCachedProviders(providers?: AgentProvider[]): void {
const providerSet = providers ? new Set(providers) : null;
const loadingEntries = this.createLoadingEntries();
@@ -577,6 +567,10 @@ export class ProviderSnapshotManager {
if (existingLoad && !options.force) {
return existingLoad.promise;
}
const existingEntry = this.snapshots.get(options.cwd)?.get(options.provider);
if (existingEntry && existingEntry.status !== "loading" && !options.force) {
return Promise.resolve();
}
const load: ProviderLoad = {
promise: Promise.resolve(),

View File

@@ -18,10 +18,13 @@ interface TestPaseoDaemonOptions {
corsAllowedOrigins?: string[];
listen?: string;
logger?: Parameters<typeof createPaseoDaemon>[1];
mcpEnabled?: boolean;
mcpDebug?: boolean;
isDev?: boolean;
relayEnabled?: boolean;
relayEndpoint?: string;
agentClients?: Partial<Record<AgentProvider, AgentClient>>;
providerOverrides?: PaseoDaemonConfig["providerOverrides"];
paseoHomeRoot?: string;
staticDir?: string;
cleanup?: boolean;
@@ -152,10 +155,12 @@ async function prepareTestDaemonConfig(
paseoHome,
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
hostnames: true,
mcpEnabled: true,
mcpEnabled: options.mcpEnabled ?? true,
staticDir,
mcpDebug: options.mcpDebug ?? false,
isDev: options.isDev,
agentClients: options.agentClients ?? createTestAgentClients(),
providerOverrides: options.providerOverrides,
agentStoragePath: path.join(paseoHome, "agents"),
relayEnabled: options.relayEnabled ?? false,
relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443",

View File

@@ -9,6 +9,16 @@ import { createTestLogger } from "../test-utils/test-logger.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { getAskModeConfig } from "./daemon-e2e/agent-configs.js";
import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js";
import type {
AgentCapabilityFlags,
AgentClient,
AgentMode,
AgentModelDefinition,
AgentPersistenceHandle,
AgentSession,
AgentSessionConfig,
ListModelsOptions,
} from "./agent/agent-sdk-types.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
@@ -18,6 +28,126 @@ const WORKSPACE_A = "wks_same_cwd_a";
const WORKSPACE_B = "wks_same_cwd_b";
const LEGACY_OWNER_WORKSPACE = "wks_legacy_owner";
const PERMISSION_WAIT_MS = 15_000;
const SNAPSHOT_STORM_PROVIDER_COUNT = 18;
const SNAPSHOT_STORM_MODELS_PER_PROVIDER = 150;
const SNAPSHOT_STORM_DESCRIPTION = "x".repeat(120);
const SNAPSHOT_STORM_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
};
class SnapshotStormProviderClient implements AgentClient {
readonly capabilities = SNAPSHOT_STORM_CAPABILITIES;
private readonly models: AgentModelDefinition[];
constructor(
readonly provider: string,
private readonly delayMs: number,
) {
this.models = Array.from({ length: SNAPSHOT_STORM_MODELS_PER_PROVIDER }, (_, index) => ({
provider,
id: `${provider}-model-${index.toString().padStart(3, "0")}`,
label: `${provider} model ${index.toString().padStart(3, "0")}`,
description: SNAPSHOT_STORM_DESCRIPTION,
isDefault: index === 0,
metadata: {
providerId: provider,
modelId: `model-${index.toString().padStart(3, "0")}`,
},
}));
}
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
throw new Error(`${this.provider} is only used for provider snapshot tests`);
}
async resumeSession(_handle: AgentPersistenceHandle): Promise<AgentSession> {
throw new Error(`${this.provider} is only used for provider snapshot tests`);
}
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
await new Promise((resolve) => setTimeout(resolve, this.delayMs));
return this.models;
}
async listModes(): Promise<AgentMode[]> {
return [];
}
async isAvailable(): Promise<boolean> {
return true;
}
}
class MetadataMockLoadTestAgentClient extends MockLoadTestAgentClient {
override async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
return [
{
provider: "mock",
id: "gpt-5.4-mini",
label: "GPT 5.4 Mini",
isDefault: true,
},
];
}
}
function createSnapshotStormClients(): SnapshotStormProviderClient[] {
return Array.from(
{ length: SNAPSHOT_STORM_PROVIDER_COUNT },
(_, index) =>
new SnapshotStormProviderClient(`snapshot-storm-${index.toString().padStart(2, "0")}`, index),
);
}
async function createSnapshotStormDaemon(clients: SnapshotStormProviderClient[]) {
return createTestPaseoDaemon({
mcpEnabled: false,
isDev: true,
agentClients: {
mock: new MetadataMockLoadTestAgentClient(),
...Object.fromEntries(clients.map((client) => [client.provider, client])),
},
providerOverrides: {
claude: { enabled: false },
codex: { enabled: false },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
omp: { enabled: false },
"mock-slow": { enabled: false },
...Object.fromEntries(
clients.map((client) => [
client.provider,
{
extends: "mock",
label: client.provider,
enabled: true,
},
]),
),
},
});
}
function collectProviderSnapshotUpdateBytes(client: DaemonClient): {
sizes: number[];
unsubscribe: () => void;
} {
const sizes: number[] = [];
const unsubscribe = client.subscribeRawMessages((message) => {
if (message.type !== "providers_snapshot_update") {
return;
}
sizes.push(JSON.stringify({ type: "session", message }).length);
});
return { sizes, unsubscribe };
}
// Seed two active workspaces that share one cwd, so we can prove agent
// ownership and status stay workspaceId-scoped — a sibling that owns nothing
@@ -234,6 +364,54 @@ test("workspace.create directory source with firstAgentContext generates a daemo
}
}, 20_000);
test("local workspace auto-title does not broadcast provider snapshot warm-up to clients", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-title-snapshot-storm-"));
const stormClients = createSnapshotStormClients();
const daemon = await createSnapshotStormDaemon(stormClients);
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
});
const snapshotUpdates = collectProviderSnapshotUpdateBytes(client);
try {
await client.connect();
await client.getProvidersSnapshot({ cwd });
await expect
.poll(
async () => {
const snapshot = await client.getProvidersSnapshot({ cwd });
return snapshot.entries.filter((entry) => entry.status === "loading").length;
},
{ timeout: 10_000 },
)
.toBe(0);
snapshotUpdates.sizes.length = 0;
const created = await client.createWorkspace({
source: { kind: "directory", path: cwd },
firstAgentContext: {
prompt: "hello",
attachments: [],
},
});
const workspaceId = created.workspace?.id;
if (!workspaceId) {
throw new Error(created.error ?? "Expected workspace to be created");
}
await expect.poll(() => workspaceName(client, workspaceId), { timeout: 10_000 }).toBe("hello");
await new Promise((resolve) => setTimeout(resolve, 500));
expect(snapshotUpdates.sizes).toEqual([]);
} finally {
snapshotUpdates.unsubscribe();
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 20_000);
test("create_agent_request with initialPrompt generates a daemon-visible workspace title", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-agent-submit-title-"));
const daemon = await createTestPaseoDaemon({