mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix provider snapshot warm reads
This commit is contained in:
@@ -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>(() => {}));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user